NFCcat: Making two phones meow at each other over NFC
I wanted a stupid thing. Point one phone at another, press a button, the other phone goes meow.
It turned out to be a good excuse to learn how phone-to-phone NFC actually works now, and it ended with a bug that took a logcat trace to see, because everything about it looked like it was working.
Android Beam is gone
The old way two Android phones talked over NFC was Android Beam. It was deprecated in Android 10 and removed entirely in Android 14. If you search for phone-to-phone NFC you will find a lot of tutorials that cannot work any more.
The modern answer is Host Card Emulation, or HCE. One phone pretends to be a contactless smart card:
- The cat phone registers a
HostApduServiceunder a custom AID, an Application ID. To the outside world it looks like a card sitting in the field. - The pointer phone turns on reader mode. When you press the button it sends a SELECT APDU naming that AID.
- Android on the cat phone routes that SELECT to the service, which plays the sound and answers
9000, which is smart card for "fine".
The AID is just an identifier both sides agree on. Mine is F0010203040506. Proprietary ones should start with F so they cannot collide with a registered one, and must be 5 to 16 bytes. Category other, not payment, so the app does not have to be your default payment app.
One phone cannot be reader and card at the same time, so both roles live in one APK and the role is decided by what you do. Whoever has the app open and presses the button is the pointer. The other phone is the cat.
The nicest part of this design: the cat phone does not need the app open. Android starts the service on demand when the AID is selected. Installing it is enough.
It worked immediately, and it was silent
The tap worked on the first try. That was the problem.
The reader logged a clean exchange. The other phone answered. The status text said "Meow sent". No meow.
I spent a while on the obvious suspects and eliminated them one by one with adb:
- Media volume on the cat phone, since the sound plays on
USAGE_MEDIAand ignores the ringer. It was 15 out of 15. - Secure NFC requiring an unlocked phone.
mIsSecureNfcEnabled=false. - The AID not being registered.
dumpsys nfcshowed the service registered and marked*DEFAULT*for it.
Then I looked at the raw APDUs on the reader side, where NxpNciX is what goes out and NxpNciR is what comes back:
NxpNciX len = 16 > 00000D00A4040007F001020304050600
NxpNciR len = 5 > 0000029000
SELECT out, 90 00 back, in 36 milliseconds. That reply can only come from my own service, so the cat phone was not just receiving the tap, it was running my code and returning success. Everything worked except the one thing the app exists to do.
At that point guessing was pointless, so I put log lines in the service and read its side of the tap.
33 milliseconds
15:01:12.963 onCreate: service starting
15:01:12.968 onCreate: load() returned sampleId=1
15:01:12.980 processCommandApdu: apdu=00A4040007F001020304050600 loaded=false
15:01:12.980 processCommandApdu: sample not ready, queued
15:01:12.993 HostEmulationManager: Unbinding from service
15:01:12.994 onDeactivated: reason=0
15:01:12.996 onDestroy: releasing SoundPool
Read the timestamps. The service is created at .963 and destroyed at .996. It exists for 33 milliseconds. Android binds it when the RF field arrives and destroys it the moment the field drops, which is as soon as you pull the phones apart.
Now notice what is not in that log. There is no onLoadComplete.
SoundPool.load() is asynchronous. The SELECT arrived 12ms after the load started, and the sample was not decoded yet, so play() on it would do nothing. I had already anticipated that much and queued the play until loading finished. That fix is in the log too, on the line that says queued.
It did not matter. Sixteen milliseconds later onDestroy ran soundPool.release() and threw the pending decode away. The load never completed because the thing doing the loading was destroyed first.
No asynchronous decoder can win that race. MediaPlayer loses it the same way. The whole approach was wrong, and it was wrong in a way that produced perfect NFC and total silence, which is a genuinely misleading failure.
Take the decoder out of the path
Two changes fixed it.
The sound file is a WAV, which means it is already uncompressed PCM. There is nothing to decode. So instead of handing it to SoundPool, parse the RIFF header and hand the samples straight to AudioTrack:
val t = AudioTrack.Builder()
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(channelMask)
.build()
)
.setBufferSizeInBytes(samples.size)
.setTransferMode(AudioTrack.MODE_STATIC)
.build()
t.write(samples, 0, samples.size)
t.play()
AudioTrack in MODE_STATIC starts synchronously. There is no callback to wait for and nothing left to race against the teardown.
The second change is smaller and more important to understand. The player is a Kotlin object, so it is process-scoped, not service-scoped, and onDestroy no longer stops playback:
override fun onDestroy() {
// Deliberately does NOT stop playback. Android destroys this service ~30ms
// after the tap, while the meow is still sounding.
super.onDestroy()
}
That comment is the whole lesson. The service being destroyed is not an error, it is the normal lifecycle. Anything that has to outlive the tap cannot be owned by the service.
Things worth knowing if you try this
- Both phones need NFC on, and the cat phone needs its screen on. Depending on the "Secure NFC" or "Require device unlock for NFC" setting it may need to be unlocked too, even though the service declares
requireDeviceUnlock="false". - Do not open the app on both phones. Two phones in reader mode are both driving the field and neither is presenting as a card, so nothing gets selected. One reader, one card, always.
- Declare the HCE feature as
required="false". Withtrue, a phone without NFC cannot install the app at all, so it can never show the "this phone has no NFC" message you wrote for exactly that case. - You cannot turn NFC on programmatically. Send the user to
Settings.ACTION_NFC_SETTINGSand re-check inonResume. - Antenna alignment is the real difficulty, not the code. The coils are small and sit in different places on different models. Slide the phones around slowly. Thick cases can kill it.
- Emulators cannot do this. Two real phones or nothing.
Debugging notes for your own tap
Check that the AID is registered and routed on a phone:
adb shell dumpsys nfc | grep -A3 F0010203040506
Watch the raw exchange on the reader side. NxpNciX is outgoing, NxpNciR incoming. If you get 6A82 back instead of 9000, the other phone does not have the app or the AID is not registered there.
And when something looks like it works but does nothing, log the lifecycle, not just the logic. I would have found this in five minutes instead of an hour if my first instinct had been to print onCreate and onDestroy.
Code and APKs are on GitHub. It is about 200 lines of Kotlin. The tap is the whole point.