From 07dd2f51738c639b9678d35277cbdae3bac26e39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:18:13 +0000 Subject: [PATCH 1/5] feat(nfc): replace tag writing with HCE Type 4 Tag emulation --- app/src/main/AndroidManifest.xml | 28 ++- .../net/duhowpi/openbeam/ShareActivity.kt | 16 +- .../openbeam/sharing/NdefHceService.kt | 164 ++++++++++++++++++ .../openbeam/sharing/NfcShareHelper.kt | 121 ++++--------- app/src/main/res/values-ca/strings.xml | 5 +- app/src/main/res/values-es/strings.xml | 5 +- app/src/main/res/values/strings.xml | 5 +- app/src/main/res/xml/apduservice.xml | 17 ++ .../metadata/android/en-US/changelogs/1.txt | 5 +- 9 files changed, 254 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/net/duhowpi/openbeam/sharing/NdefHceService.kt create mode 100644 app/src/main/res/xml/apduservice.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 56d13d0..33e2c00 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + @@ -66,17 +68,25 @@ - - - - - + + + + - - + - - + + diff --git a/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt b/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt index d81e131..4eb6445 100644 --- a/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt +++ b/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt @@ -41,7 +41,7 @@ import net.duhowpi.openbeam.util.SoundManager * user cancels), the activity finishes immediately. * * Share flow: - * • text/plain, text/vcard → NDEF message → NFC tag write + * • text/plain, text/vcard → NDEF message → HCE emulation (NFC Type 4 Tag) * • image (any) → QR scan → NDEF (if QR found) * → Wi-Fi Direct (if no QR / NFC unavailable) * @@ -110,12 +110,14 @@ class ShareActivity : AppCompatActivity() { super.onResume() wifiShare.register() - // NFC foreground dispatch MUST be enabled from onResume (NFC API requirement). - // shareViaNfc() stores the message; we enable dispatch here. + // HCE content must be registered while the activity is in the foreground. + // shareViaNfc() stores the message; we activate HCE here so it is + // cleared in onPause and never serves content when the screen is off or + // the activity is in the background. val msg = pendingNfcMessage val cb = nfcStateCallback if (msg != null && cb != null) { - Log.d(TAG, "onResume – enabling NFC foreground dispatch") + Log.d(TAG, "onResume – activating HCE sharing") nfcHelper.startSharing(msg, cb) } } @@ -126,11 +128,11 @@ class ShareActivity : AppCompatActivity() { wifiShare.unregister() } - /** Forward any remaining intents received while activity is on top (singleTop). */ + /** Forward any remaining share intents received while activity is on top (singleTop). */ override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - // NFC is now handled via NfcAdapter.enableReaderMode callback in NfcShareHelper; - // no NFC intents are dispatched to the activity while reader mode is active. + // NFC NDEF sharing is handled by NdefHceService (HCE); no NFC intents are dispatched + // to the activity. Only new ACTION_SEND intents can arrive here. } override fun onRequestPermissionsResult( diff --git a/app/src/main/java/net/duhowpi/openbeam/sharing/NdefHceService.kt b/app/src/main/java/net/duhowpi/openbeam/sharing/NdefHceService.kt new file mode 100644 index 0000000..8e31616 --- /dev/null +++ b/app/src/main/java/net/duhowpi/openbeam/sharing/NdefHceService.kt @@ -0,0 +1,164 @@ +package net.duhowpi.openbeam.sharing + +import android.nfc.cardemulation.HostApduService +import android.os.Bundle +import android.util.Log + +/** + * HCE (Host Card Emulation) service that emulates an NFC Type 4 Tag (T4T) containing an NDEF + * message, conforming to the NFC Forum Type 4 Tag Technical Specification. + * + * This allows any NFC-capable reader — Android, iOS, or dedicated hardware — to read NDEF content + * from this device by tapping, without writing to any physical NFC tag. + * + * APDU protocol flow (per NFC Forum T4T spec): + * 1. SELECT NDEF Application by AID (D2760000850101) + * 2. SELECT Capability Container file (E103) + * 3. READ BINARY – CC file + * 4. SELECT NDEF file (E104) + * 5. READ BINARY – NDEF file (NLEN + NDEF message bytes) + * + * Usage: + * - Set [pendingNdef] with the raw NDEF message bytes before sharing begins. + * - Optionally register [onConnected] and [onComplete] callbacks. + * - Clear [pendingNdef] (and the callbacks) when sharing ends to prevent unintended reads. + */ +class NdefHceService : HostApduService() { + + companion object { + private const val TAG = "NdefHceService" + + // NFC Forum NDEF Application AID (Type 4 Tag) + private val NDEF_AID = byteArrayOf( + 0xD2.toByte(), 0x76, 0x00, 0x00, 0x85.toByte(), 0x01, 0x01, + ) + + private val CC_FILE_ID = byteArrayOf(0xE1.toByte(), 0x03) + private val NDEF_FILE_ID = byteArrayOf(0xE1.toByte(), 0x04) + + // ISO 7816-4 status words + private val SW_OK = byteArrayOf(0x90.toByte(), 0x00) + private val SW_NOT_FOUND = byteArrayOf(0x6A.toByte(), 0x82.toByte()) + private val SW_WRONG_LENGTH = byteArrayOf(0x67.toByte(), 0x00) + private val SW_WRONG_P1P2 = byteArrayOf(0x6B.toByte(), 0x00) + private val SW_FUNC_NOT_SUPPORTED = byteArrayOf(0x6A.toByte(), 0x81.toByte()) + + /** + * Fixed Capability Container file (T4T v2.0, 15 bytes, read-only NDEF). + * Max NDEF file size set to 32 767 bytes; write access set to prohibited (0xFF). + */ + private val CC_FILE = byteArrayOf( + 0x00, 0x0F, // CCLEN = 15 + 0x20, // Mapping Version 2.0 + 0x00, 0x7F, // MLe: max R-APDU data = 127 + 0x00, 0x59, // MLc: max C-APDU data = 89 + 0x04, // NDEF File Control TLV tag + 0x06, // TLV value length = 6 + 0xE1.toByte(), 0x04, // NDEF File ID + 0x7F.toByte(), 0xFF.toByte(), // Max NDEF file size = 32 767 + 0x00, // Read access: free + 0xFF.toByte(), // Write access: prohibited + ) + + /** Raw NDEF message bytes to serve. Set before sharing; clear to stop serving. */ + @Volatile var pendingNdef: ByteArray? = null + + /** Called on the NFC thread when a reader selects the NDEF Application. */ + @Volatile var onConnected: (() -> Unit)? = null + + /** Called on the NFC thread after [onDeactivated] once NDEF data was served. */ + @Volatile var onComplete: (() -> Unit)? = null + } + + private enum class SelectedFile { NONE, CC, NDEF } + + private var selectedFile = SelectedFile.NONE + private var ndefReadStarted = false + + override fun processCommandApdu(apdu: ByteArray, extras: Bundle?): ByteArray { + if (apdu.size < 4) return SW_WRONG_LENGTH + + val ins = apdu[1] + val p1 = apdu[2] + val p2 = apdu[3] + + return when { + // SELECT by AID (INS=A4, P1=04, P2=00) + ins == 0xA4.toByte() && p1 == 0x04.toByte() && p2 == 0x00.toByte() -> { + if (apdu.size < 5) return SW_WRONG_LENGTH + val lc = apdu[4].toInt() and 0xFF + if (apdu.size < 5 + lc) return SW_WRONG_LENGTH + val aid = apdu.sliceArray(5 until 5 + lc) + if (aid.contentEquals(NDEF_AID) && pendingNdef != null) { + selectedFile = SelectedFile.NONE + Log.d(TAG, "SELECT NDEF Application → OK") + onConnected?.invoke() + SW_OK + } else { + SW_NOT_FOUND + } + } + + // SELECT by File ID (INS=A4, P1=00, P2=0C) + ins == 0xA4.toByte() && p1 == 0x00.toByte() && p2 == 0x0C.toByte() -> { + if (apdu.size < 5) return SW_WRONG_LENGTH + val lc = apdu[4].toInt() and 0xFF + if (apdu.size < 5 + lc) return SW_WRONG_LENGTH + val fileId = apdu.sliceArray(5 until 5 + lc) + when { + fileId.contentEquals(CC_FILE_ID) -> { selectedFile = SelectedFile.CC; SW_OK } + fileId.contentEquals(NDEF_FILE_ID) -> { selectedFile = SelectedFile.NDEF; SW_OK } + else -> SW_NOT_FOUND + } + } + + // READ BINARY (INS=B0) + ins == 0xB0.toByte() -> { + if (apdu.size < 5) return SW_WRONG_LENGTH + val offset = ((p1.toInt() and 0xFF) shl 8) or (p2.toInt() and 0xFF) + val le = apdu[4].toInt() and 0xFF + readBinary(offset, le) + } + + else -> SW_FUNC_NOT_SUPPORTED + } + } + + override fun onDeactivated(reason: Int) { + Log.d(TAG, "onDeactivated reason=$reason ndefReadStarted=$ndefReadStarted") + if (ndefReadStarted) { + onComplete?.invoke() + } + selectedFile = SelectedFile.NONE + ndefReadStarted = false + } + + // ------------------------------------------------------------------------- + + private fun readBinary(offset: Int, le: Int): ByteArray { + val file: ByteArray = when (selectedFile) { + SelectedFile.CC -> CC_FILE + SelectedFile.NDEF -> { + val ndef = pendingNdef ?: return SW_NOT_FOUND + // NDEF file = 2-byte NLEN + NDEF message + byteArrayOf( + ((ndef.size shr 8) and 0xFF).toByte(), + (ndef.size and 0xFF).toByte(), + ) + ndef + } + SelectedFile.NONE -> return SW_NOT_FOUND + } + + if (offset > file.size) return SW_WRONG_P1P2 + val readLen = if (le == 0) file.size - offset else le + val end = minOf(offset + readLen, file.size) + val data = file.sliceArray(offset until end) + + if (selectedFile == SelectedFile.NDEF) { + ndefReadStarted = true + } + + Log.d(TAG, "READ BINARY file=$selectedFile offset=$offset le=$le → ${data.size} bytes") + return data + SW_OK + } +} diff --git a/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt b/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt index 420ecce..aa2419b 100644 --- a/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt +++ b/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt @@ -1,42 +1,42 @@ package net.duhowpi.openbeam.sharing import android.app.Activity +import android.content.pm.PackageManager import android.nfc.NdefMessage import android.nfc.NfcAdapter -import android.nfc.Tag -import android.nfc.tech.Ndef -import android.nfc.tech.NdefFormatable /** - * Manages NFC reader mode and NDEF tag writing. + * Manages NFC NDEF sharing via Host Card Emulation (HCE). * * Usage: - * 1. Call [startSharing] with the message to send and a callback (from onResume). + * 1. Call [startSharing] with the NDEF message to share (from onResume). * 2. Call [stopSharing] in onPause or when done. * - * Uses [NfcAdapter.enableReaderMode] instead of foreground dispatch so that: - * - The platform NFC tap sound is suppressed ([NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS]). - * - Host Card Emulation (HCE/payment) is paused while sharing is active, preventing the - * device from responding to external payment terminals. - * - Other NFC apps are blocked from receiving tags while the activity is in the foreground. + * The device emulates an NFC Type 4 Tag (T4T) using [NdefHceService], allowing any + * NFC-capable reader — another Android device, iPhone (iOS 13+), or dedicated hardware — + * to tap and read the NDEF content. No physical NFC tag is written. * - * Android Beam (`setNdefPushMessage`) was removed in API 34. + * [NdefHceService] is bound by the NFC subsystem when a reader selects the NDEF Application + * AID; [startSharing] / [stopSharing] gate the content and callback via the service's + * companion object so that HCE only responds while the activity is in the foreground. */ class NfcShareHelper(private val activity: Activity) { private val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(activity) - @Volatile private var pendingMessage: NdefMessage? = null - @Volatile private var onResult: ((NfcShareState) -> Unit)? = null - - /** True if NFC hardware is present and enabled on this device. */ + /** + * True if NFC hardware is present, enabled, and the device supports HCE. + * Used to decide whether to attempt NFC sharing or fall back to Wi-Fi Direct. + */ val isAvailable: Boolean - get() = adapter?.isEnabled == true + get() = adapter?.isEnabled == true && + activity.packageManager.hasSystemFeature(PackageManager.FEATURE_NFC_HOST_CARD_EMULATION) /** - * Enable NFC reader mode so the activity can catch tag discoveries. - * Must be called from Activity.onResume (NFC API requirement). - * @param message NDEF message to write when a tag is tapped. + * Register [message] for HCE emulation so the next reader tap can receive it. + * Should be called from Activity.onResume (paired with [stopSharing] in onPause). + * + * @param message NDEF message to emit when another device taps. * @param onResult Callback invoked with the result state (may be called from a background thread). */ fun startSharing(message: NdefMessage, onResult: (NfcShareState) -> Unit) { @@ -44,93 +44,34 @@ class NfcShareHelper(private val activity: Activity) { onResult(NfcShareState.Unsupported) return } - this.pendingMessage = message - this.onResult = onResult - enableReaderMode() + NdefHceService.pendingNdef = message.toByteArray() + NdefHceService.onConnected = { onResult(NfcShareState.Writing) } + NdefHceService.onComplete = { onResult(NfcShareState.Success) } onResult(NfcShareState.Waiting) } - /** Disable reader mode. Call from Activity.onPause. */ + /** Clear HCE content so the service no longer responds. Call from Activity.onPause. */ fun stopSharing() { - adapter?.disableReaderMode(activity) - pendingMessage = null - onResult = null - } - - // ------------------------------------------------------------------------- - // Private helpers - // ------------------------------------------------------------------------- - - private fun enableReaderMode() { - // Listen for all common NFC tag technologies. - // FLAG_READER_NO_PLATFORM_SOUNDS suppresses the OS tap sound when any tag is detected, - // so non-NDEF tags (e.g. EMV payment cards) are silently ignored. - val flags = NfcAdapter.FLAG_READER_NFC_A or - NfcAdapter.FLAG_READER_NFC_B or - NfcAdapter.FLAG_READER_NFC_F or - NfcAdapter.FLAG_READER_NFC_V or - NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS - adapter?.enableReaderMode(activity, ::onTagDiscovered, flags, null) - } - - /** - * Called by the NFC subsystem on a background thread when a tag is detected. - * Non-NDEF tags (e.g. EMV payment cards) are silently ignored so no error is shown. - */ - private fun onTagDiscovered(tag: Tag) { - val message = pendingMessage ?: return - val callback = onResult ?: return - - // Ignore tags that support neither NDEF nor NdefFormatable (e.g. payment cards). - if (Ndef.get(tag) == null && NdefFormatable.get(tag) == null) return - - callback(NfcShareState.Writing) - val success = writeNdef(tag, message) - callback(if (success) NfcShareState.Success else NfcShareState.Error) - } - - /** Write [message] to [tag]. Returns true on success. */ - private fun writeNdef(tag: Tag, message: NdefMessage): Boolean { - // Try NDEF first (tag already formatted) - Ndef.get(tag)?.let { ndef -> - return runCatching { - ndef.connect() - check(ndef.isWritable) { "Tag is read-only" } - check(ndef.maxSize >= message.byteArrayLength) { "Message too large for tag" } - ndef.writeNdefMessage(message) - ndef.close() - true - }.getOrElse { false } - } - - // Try NdefFormatable (blank tag that needs formatting) - NdefFormatable.get(tag)?.let { formatable -> - return runCatching { - formatable.connect() - formatable.format(message) - formatable.close() - true - }.getOrElse { false } - } - - return false + NdefHceService.pendingNdef = null + NdefHceService.onConnected = null + NdefHceService.onComplete = null } } /** States emitted during an NFC share operation. */ sealed class NfcShareState { - /** Foreground dispatch active, waiting for user to tap a tag. */ + /** HCE active, waiting for another device to tap. */ object Waiting : NfcShareState() - /** Tag detected, writing NDEF message. */ + /** Another device has connected and is reading the NDEF message. */ object Writing : NfcShareState() - /** Message written successfully. */ + /** NDEF message was read successfully by the other device. */ object Success : NfcShareState() - /** Write failed (tag not writable, too small, I/O error). */ + /** Sharing failed unexpectedly. */ object Error : NfcShareState() - /** NFC is not available or disabled on this device. */ + /** NFC or HCE is not available or disabled on this device. */ object Unsupported : NfcShareState() } diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index b23e8ab..816db00 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -4,7 +4,7 @@ Acosta\'l a un altre dispositiu Cercant codi QR… - Escrivint… + Enviant… Fet! Alguna cosa ha anat malament Tipus de contingut no compatible @@ -33,4 +33,7 @@ Info de diagnòstic copiada — enganxa-la a l\'informe d\'error + + + Compartició NDEF d\'OpenBeam diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 225d67b..d453dbf 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -4,7 +4,7 @@ Acerca el dispositivo a otro Buscando código QR… - Escribiendo… + Enviando… ¡Listo! Algo salió mal Tipo de contenido no compatible @@ -33,4 +33,7 @@ Info de diagnóstico copiada — pégala en tu informe de error + + + Compartición NDEF de OpenBeam diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 04739c8..8bde16f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,7 +4,7 @@ Hold near another device Scanning for QR code… - Writing… + Sending… Done! Something went wrong Unsupported content type @@ -33,4 +33,7 @@ Diagnostic info copied — paste it in your bug report + + + OpenBeam NDEF share diff --git a/app/src/main/res/xml/apduservice.xml b/app/src/main/res/xml/apduservice.xml new file mode 100644 index 0000000..440dfc0 --- /dev/null +++ b/app/src/main/res/xml/apduservice.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt index 50d59ef..c604002 100644 --- a/fastlane/metadata/android/en-US/changelogs/1.txt +++ b/fastlane/metadata/android/en-US/changelogs/1.txt @@ -1,11 +1,10 @@ Initial release. -- Share URLs, vCards and plain text via NFC tag write +- Share URLs, vCards and plain text via NFC (HCE emulation — no physical tag required) - Scan images for QR codes and beam the decoded content (URLs, Wi-Fi credentials) via NFC - Wi-Fi Direct fallback when image has no QR code or NFC is unavailable - Supports English, Spanish and Catalan Fixes: - Removed duplicate app title shown outside the share dialog -- NFC tap sound no longer plays when a non-writable tag (e.g. a payment card) is nearby during sharing -- Payment card emulation (HCE) is now paused while sharing is active, blocking unintended NFC payment reads \ No newline at end of file +- NFC sharing now uses Host Card Emulation (HCE) instead of writing to physical tags; Mifare/NTAG2xx tags are never modified \ No newline at end of file From 7391c19dff17f1d19a594ef20be8675137f8fba2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:18:50 +0000 Subject: [PATCH 2/5] docs: improve changelog wording for HCE change --- fastlane/metadata/android/en-US/changelogs/1.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt index c604002..fa25200 100644 --- a/fastlane/metadata/android/en-US/changelogs/1.txt +++ b/fastlane/metadata/android/en-US/changelogs/1.txt @@ -7,4 +7,4 @@ Initial release. Fixes: - Removed duplicate app title shown outside the share dialog -- NFC sharing now uses Host Card Emulation (HCE) instead of writing to physical tags; Mifare/NTAG2xx tags are never modified \ No newline at end of file +- NFC sharing no longer requires a physical tag — just tap devices together \ No newline at end of file From bdccbbacc61fa43d05500be71b4c347f41b20a6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:30:25 +0000 Subject: [PATCH 3/5] fix(nfc): suppress tag polling while HCE emulation is active --- .../net/duhowpi/openbeam/ShareActivity.kt | 8 +++--- .../openbeam/sharing/NfcShareHelper.kt | 25 +++++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt b/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt index 4eb6445..c5aec25 100644 --- a/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt +++ b/app/src/main/java/net/duhowpi/openbeam/ShareActivity.kt @@ -66,9 +66,9 @@ class ShareActivity : AppCompatActivity() { private lateinit var btnDebug: Button /** - * NFC message ready to write, prepared in [handleShareIntent] (called from [onCreate]). - * Actual reader mode is enabled only in [onResume] because - * [android.nfc.NfcAdapter.enableReaderMode] requires the activity to be resumed. + * NFC message ready to emit via HCE, prepared in [handleShareIntent] (called from [onCreate]). + * HCE activation and tag-polling suppression happen in [onResume] so they are + * automatically undone in [onPause] whenever the activity leaves the foreground. */ private var pendingNfcMessage: NdefMessage? = null private var nfcStateCallback: ((NfcShareState) -> Unit)? = null @@ -269,7 +269,7 @@ class ShareActivity : AppCompatActivity() { pendingNfcMessage = message nfcStateCallback = callback - // enableReaderMode() requires the activity to be resumed. + // startSharing() requires the activity to be resumed (NfcAdapter API requirement). // If already resumed (e.g. called after QR scan), start immediately; // otherwise onResume() will pick up pendingNfcMessage and start it. if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { diff --git a/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt b/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt index aa2419b..7b2ef0b 100644 --- a/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt +++ b/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt @@ -16,6 +16,13 @@ import android.nfc.NfcAdapter * NFC-capable reader — another Android device, iPhone (iOS 13+), or dedicated hardware — * to tap and read the NDEF content. No physical NFC tag is written. * + * While sharing is active, [startSharing] calls [NfcAdapter.enableReaderMode] with + * [NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS] and **no technology flags**. This tells + * the NFC stack to stop polling for physical tags (Mifare, NTAG2xx, ISO-DEP, etc.) + * without disabling HCE, so the device acts purely as a card emulator and will not + * inadvertently read any tag brought near it. [stopSharing] calls [NfcAdapter.disableReaderMode] + * to restore normal tag-polling behaviour when sharing ends. + * * [NdefHceService] is bound by the NFC subsystem when a reader selects the NDEF Application * AID; [startSharing] / [stopSharing] gate the content and callback via the service's * companion object so that HCE only responds while the activity is in the foreground. @@ -33,7 +40,8 @@ class NfcShareHelper(private val activity: Activity) { activity.packageManager.hasSystemFeature(PackageManager.FEATURE_NFC_HOST_CARD_EMULATION) /** - * Register [message] for HCE emulation so the next reader tap can receive it. + * Register [message] for HCE emulation so the next reader tap can receive it, and disable + * physical-tag polling so the device behaves purely as a card emulator. * Should be called from Activity.onResume (paired with [stopSharing] in onPause). * * @param message NDEF message to emit when another device taps. @@ -47,11 +55,24 @@ class NfcShareHelper(private val activity: Activity) { NdefHceService.pendingNdef = message.toByteArray() NdefHceService.onConnected = { onResult(NfcShareState.Writing) } NdefHceService.onComplete = { onResult(NfcShareState.Success) } + + // Disable physical-tag polling while HCE is active. + // FLAG_READER_NO_PLATFORM_SOUNDS with no technology flags (NFC_A/B/F/V) stops the + // NFC stack from discovering tags without pausing card emulation (HCE). This prevents + // the device from reading Mifare/NTAG2xx/ISO-DEP tags placed near it during sharing. + adapter?.enableReaderMode( + activity, + { /* tag callback intentionally empty – we are the card, not the reader */ }, + NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS, + null, + ) + onResult(NfcShareState.Waiting) } - /** Clear HCE content so the service no longer responds. Call from Activity.onPause. */ + /** Restore normal NFC tag polling and clear HCE content. Call from Activity.onPause. */ fun stopSharing() { + adapter?.disableReaderMode(activity) NdefHceService.pendingNdef = null NdefHceService.onConnected = null NdefHceService.onComplete = null From 1b0250d49d6d0a4e691cfa39e2ba1609ca48c779 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:32:59 +0000 Subject: [PATCH 4/5] fix(nfc): suppress tag polling while HCE emulation is active --- .../openbeam/sharing/NdefHceService.kt | 54 +++++++++++++------ .../openbeam/sharing/NfcShareHelper.kt | 20 ++++--- .../metadata/android/en-US/changelogs/1.txt | 3 +- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/net/duhowpi/openbeam/sharing/NdefHceService.kt b/app/src/main/java/net/duhowpi/openbeam/sharing/NdefHceService.kt index 8e31616..626a7a4 100644 --- a/app/src/main/java/net/duhowpi/openbeam/sharing/NdefHceService.kt +++ b/app/src/main/java/net/duhowpi/openbeam/sharing/NdefHceService.kt @@ -19,16 +19,38 @@ import android.util.Log * 5. READ BINARY – NDEF file (NLEN + NDEF message bytes) * * Usage: - * - Set [pendingNdef] with the raw NDEF message bytes before sharing begins. - * - Optionally register [onConnected] and [onComplete] callbacks. - * - Clear [pendingNdef] (and the callbacks) when sharing ends to prevent unintended reads. + * - Set [session] with a [HceSession] before sharing begins. + * - Clear [session] (set to null) when sharing ends to prevent unintended reads. + * - The NDEF data, onConnected, and onComplete callbacks are held together in a single + * volatile reference so they are always updated atomically. */ class NdefHceService : HostApduService() { + /** + * Holds all state for one active HCE sharing session. + * Stored as a single [session] volatile reference so that start and stop operations + * are atomic — the service either sees a complete session or nothing. + */ + data class HceSession( + val ndefBytes: ByteArray, + /** Called on the NFC thread when a reader selects the NDEF Application. */ + val onConnected: () -> Unit, + /** Called on the NFC thread after [onDeactivated] once NDEF data was served. */ + val onComplete: () -> Unit, + ) { + // ByteArray equality is identity by default; override so data class equals() is useful. + override fun equals(other: Any?): Boolean = + other is HceSession && ndefBytes.contentEquals(other.ndefBytes) + override fun hashCode(): Int = ndefBytes.contentHashCode() + } + companion object { private const val TAG = "NdefHceService" - // NFC Forum NDEF Application AID (Type 4 Tag) + // NFC Forum NDEF Application AID (Type 4 Tag, T4T spec §5.2): + // D2 76 00 00 85 – NFC Forum RID + // 01 – PIX (application family: NDEF) + // 01 – version byte private val NDEF_AID = byteArrayOf( 0xD2.toByte(), 0x76, 0x00, 0x00, 0x85.toByte(), 0x01, 0x01, ) @@ -60,14 +82,12 @@ class NdefHceService : HostApduService() { 0xFF.toByte(), // Write access: prohibited ) - /** Raw NDEF message bytes to serve. Set before sharing; clear to stop serving. */ - @Volatile var pendingNdef: ByteArray? = null - - /** Called on the NFC thread when a reader selects the NDEF Application. */ - @Volatile var onConnected: (() -> Unit)? = null - - /** Called on the NFC thread after [onDeactivated] once NDEF data was served. */ - @Volatile var onComplete: (() -> Unit)? = null + /** + * Active sharing session. A single volatile reference ensures that ndefBytes, + * onConnected, and onComplete are always read as a consistent unit — no partial + * state is visible between a set and a clear in [NfcShareHelper]. + */ + @Volatile var session: HceSession? = null } private enum class SelectedFile { NONE, CC, NDEF } @@ -89,10 +109,11 @@ class NdefHceService : HostApduService() { val lc = apdu[4].toInt() and 0xFF if (apdu.size < 5 + lc) return SW_WRONG_LENGTH val aid = apdu.sliceArray(5 until 5 + lc) - if (aid.contentEquals(NDEF_AID) && pendingNdef != null) { + val s = session + if (aid.contentEquals(NDEF_AID) && s != null) { selectedFile = SelectedFile.NONE Log.d(TAG, "SELECT NDEF Application → OK") - onConnected?.invoke() + s.onConnected() SW_OK } else { SW_NOT_FOUND @@ -127,7 +148,7 @@ class NdefHceService : HostApduService() { override fun onDeactivated(reason: Int) { Log.d(TAG, "onDeactivated reason=$reason ndefReadStarted=$ndefReadStarted") if (ndefReadStarted) { - onComplete?.invoke() + session?.onComplete() } selectedFile = SelectedFile.NONE ndefReadStarted = false @@ -139,7 +160,8 @@ class NdefHceService : HostApduService() { val file: ByteArray = when (selectedFile) { SelectedFile.CC -> CC_FILE SelectedFile.NDEF -> { - val ndef = pendingNdef ?: return SW_NOT_FOUND + // Capture session once to avoid a TOCTOU race if it is cleared concurrently. + val ndef = session?.ndefBytes ?: return SW_NOT_FOUND // NDEF file = 2-byte NLEN + NDEF message byteArrayOf( ((ndef.size shr 8) and 0xFF).toByte(), diff --git a/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt b/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt index 7b2ef0b..b024334 100644 --- a/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt +++ b/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt @@ -24,8 +24,9 @@ import android.nfc.NfcAdapter * to restore normal tag-polling behaviour when sharing ends. * * [NdefHceService] is bound by the NFC subsystem when a reader selects the NDEF Application - * AID; [startSharing] / [stopSharing] gate the content and callback via the service's - * companion object so that HCE only responds while the activity is in the foreground. + * AID; [startSharing] / [stopSharing] gate the content and callbacks via a single atomic + * [NdefHceService.session] reference so that HCE only responds while the activity is in the + * foreground and the service never observes a partially-initialised state. */ class NfcShareHelper(private val activity: Activity) { @@ -52,9 +53,13 @@ class NfcShareHelper(private val activity: Activity) { onResult(NfcShareState.Unsupported) return } - NdefHceService.pendingNdef = message.toByteArray() - NdefHceService.onConnected = { onResult(NfcShareState.Writing) } - NdefHceService.onComplete = { onResult(NfcShareState.Success) } + // Publish all session state atomically via a single volatile reference so that + // the service never sees a partial state (e.g. ndefBytes set but callbacks still null). + NdefHceService.session = NdefHceService.HceSession( + ndefBytes = message.toByteArray(), + onConnected = { onResult(NfcShareState.Writing) }, + onComplete = { onResult(NfcShareState.Success) }, + ) // Disable physical-tag polling while HCE is active. // FLAG_READER_NO_PLATFORM_SOUNDS with no technology flags (NFC_A/B/F/V) stops the @@ -73,9 +78,8 @@ class NfcShareHelper(private val activity: Activity) { /** Restore normal NFC tag polling and clear HCE content. Call from Activity.onPause. */ fun stopSharing() { adapter?.disableReaderMode(activity) - NdefHceService.pendingNdef = null - NdefHceService.onConnected = null - NdefHceService.onComplete = null + // Clear atomically so the service never sees a session with null callbacks. + NdefHceService.session = null } } diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt index fa25200..e911a44 100644 --- a/fastlane/metadata/android/en-US/changelogs/1.txt +++ b/fastlane/metadata/android/en-US/changelogs/1.txt @@ -7,4 +7,5 @@ Initial release. Fixes: - Removed duplicate app title shown outside the share dialog -- NFC sharing no longer requires a physical tag — just tap devices together \ No newline at end of file +- NFC sharing no longer requires a physical tag — just tap devices together +- Device no longer reads physical NFC tags while sharing is active \ No newline at end of file From ba3f9cc25118a8abd43e51242fd19d5d1a8c6cc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:44:14 +0000 Subject: [PATCH 5/5] fix(nfc): add technology flags to enableReaderMode to suppress NDEF dispatch --- .../openbeam/sharing/NfcShareHelper.kt | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt b/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt index b024334..6587b29 100644 --- a/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt +++ b/app/src/main/java/net/duhowpi/openbeam/sharing/NfcShareHelper.kt @@ -16,12 +16,14 @@ import android.nfc.NfcAdapter * NFC-capable reader — another Android device, iPhone (iOS 13+), or dedicated hardware — * to tap and read the NDEF content. No physical NFC tag is written. * - * While sharing is active, [startSharing] calls [NfcAdapter.enableReaderMode] with - * [NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS] and **no technology flags**. This tells - * the NFC stack to stop polling for physical tags (Mifare, NTAG2xx, ISO-DEP, etc.) - * without disabling HCE, so the device acts purely as a card emulator and will not - * inadvertently read any tag brought near it. [stopSharing] calls [NfcAdapter.disableReaderMode] - * to restore normal tag-polling behaviour when sharing ends. + * While sharing is active, [startSharing] calls [NfcAdapter.enableReaderMode] with all four + * NFC technology flags (NFC_A, NFC_B, NFC_F, NFC_V) plus [NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK] + * and [NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS]. This puts the NFC stack into reader mode for + * every tag type, routing any physical tag discovery to our no-op callback instead of the OS + * NDEF/TAG intent dispatch, so the device will not inadvertently open URLs or launch apps when a + * physical NFC tag (Mifare, NTAG2xx, ISO-DEP, etc.) is brought near it. HCE card emulation runs + * on an independent path in the NFC controller and remains active throughout. [stopSharing] calls + * [NfcAdapter.disableReaderMode] to restore normal tag-dispatch behaviour when sharing ends. * * [NdefHceService] is bound by the NFC subsystem when a reader selects the NDEF Application * AID; [startSharing] / [stopSharing] gate the content and callbacks via a single atomic @@ -41,8 +43,8 @@ class NfcShareHelper(private val activity: Activity) { activity.packageManager.hasSystemFeature(PackageManager.FEATURE_NFC_HOST_CARD_EMULATION) /** - * Register [message] for HCE emulation so the next reader tap can receive it, and disable - * physical-tag polling so the device behaves purely as a card emulator. + * Register [message] for HCE emulation so the next reader tap can receive it, and suppress + * physical-tag dispatch so the device behaves purely as a card emulator. * Should be called from Activity.onResume (paired with [stopSharing] in onPause). * * @param message NDEF message to emit when another device taps. @@ -61,13 +63,23 @@ class NfcShareHelper(private val activity: Activity) { onComplete = { onResult(NfcShareState.Success) }, ) - // Disable physical-tag polling while HCE is active. - // FLAG_READER_NO_PLATFORM_SOUNDS with no technology flags (NFC_A/B/F/V) stops the - // NFC stack from discovering tags without pausing card emulation (HCE). This prevents - // the device from reading Mifare/NTAG2xx/ISO-DEP tags placed near it during sharing. + // Suppress physical-tag dispatch while HCE is active. + // enableReaderMode with ALL technology flags (NFC_A/B/F/V) puts the NFC stack into + // reader mode for every tag type. Any physical tag (Mifare, NTAG2xx, ISO-DEP, FeliCa, + // ISO 15693) discovered during sharing is routed to our no-op callback instead of being + // dispatched via NDEF_DISCOVERED / TAG_DISCOVERED intents, so the OS will not open URLs + // or launch other apps. FLAG_READER_SKIP_NDEF_CHECK skips the slow NDEF-compatibility + // check on discovered tags (we ignore them anyway). FLAG_READER_NO_PLATFORM_SOUNDS + // suppresses the NFC discovery sound. HCE card emulation runs on an independent path + // in the NFC controller and is unaffected by reader mode. adapter?.enableReaderMode( activity, - { /* tag callback intentionally empty – we are the card, not the reader */ }, + { /* physical tag discovered during sharing – intentionally ignored */ }, + NfcAdapter.FLAG_READER_NFC_A or + NfcAdapter.FLAG_READER_NFC_B or + NfcAdapter.FLAG_READER_NFC_F or + NfcAdapter.FLAG_READER_NFC_V or + NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK or NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS, null, )