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,
)