A small, dependency-light Kotlin library for reading, writing, and password-protecting
NTAG213 / NTAG215 / NTAG216 NFC tags on Android, built directly on the low-level NfcA API.
Hand it any discovered Tag and it auto-detects which of the three variants it is, so the same
code (page addressing, memory capacity, everything) works correctly across all of them.
val toolkit = tag.toNtagToolkit() // suspend: connects + detects NTAG213/215/216
toolkit.use {
val message = readNdefMessage()
setPassword("1234".toByteArray())
setPasswordRequiredForReading(true)
}- Why NtagToolkit
- Install
- Getting started
- API reference
- Concurrency
- Sample app
- Testing
- Datasheet
- License
NFC Forum Type 2 tags all speak roughly the same commands, but the page addresses for user
memory, the config area, AUTH0/ACCESS/PWD/PACK, and the total capacity all differ between
NTAG213, NTAG215, and NTAG216, and Android's own NfcA/Ndef APIs give you none of that, no
password support, and no NDEF chunking/padding. NtagToolkit fills that gap.
NtagToolkit.create(tag) sends GET_VERSION and matches it against NTAG213/215/216, so you never
hardcode a spec or memory size. It's the only entry point: a single suspend factory, no
NfcA/Ndef/MifareUltralight classes to juggle, and no manual connect()/close()
bookkeeping if you use the scoped use { } helper. Full NDEF read/write is covered, including
erasing a tag, correctly clamped to the variant's actual user-memory boundary so the last chunk
never overruns into the config pages. Password protection is split cleanly into "requires a
password to write" (setPassword) and "also requires one to read"
(setPasswordRequiredForReading), both queryable without authenticating first. Every operation
throws a typed error instead of a raw IOException/FormatException/NPE, so callers can when
over failure modes. Every public suspend call is serialized through an internal Mutex, so one
toolkit instance can be called safely from multiple coroutines without interleaving commands
mid-write. The only runtime dependency is kotlinx-coroutines-core.
Published via JitPack:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://jitpack.io")
}
}// build.gradle.kts (app module)
dependencies {
implementation("com.github.lucf15:NtagToolkit:<version>")
}Requires minSdk 24+ and the android.permission.NFC permission / android.hardware.nfc
feature in your app's manifest.
Obtain an Android Tag however you normally would (foreground dispatch, an NFC intent filter, or
NfcAdapter.ReaderCallback) and hand it to toNtagToolkit() (or NtagToolkit.create(tag),
which it's sugar for) from a coroutine:
override fun onTagDiscovered(tag: Tag) {
lifecycleScope.launch {
try {
val toolkit = tag.toNtagToolkit()
toolkit.use {
Log.d("NFC", "Found a $variant tag, uid=${uid?.toHexString()}")
val message = readNdefMessage()
// ...
}
} catch (e: NtagToolkitException) {
// NotConnected, UnsupportedTag, etc. See Errors below.
}
}
}create(tag) connects to the tag and runs GET_VERSION as part of detection, so the toolkit it
returns is already connected: you don't need to call connect() yourself before your first
operation. use { } is the recommended wrapper: it guarantees disconnect() runs even if your
block throws, and forwards the block's return value.
suspend fun NtagToolkit.Companion.create(tag: Tag): NtagToolkit
suspend fun Tag.toNtagToolkit(): NtagToolkit // shorthand for create(this)
suspend fun <T> NtagToolkit.use(block: suspend NtagToolkit.() -> T): T
suspend fun NtagToolkit.connect()
suspend fun NtagToolkit.disconnect()create/toNtagToolkit are the only way to obtain a NtagToolkit: there's no public
constructor, so a toolkit is never in an unconfigured or wrong-variant state. They throw
NotConnected if the tag doesn't support NfcA, or UnsupportedTag if it responds to
GET_VERSION with something other than an NTAG213/215/216 signature.
use { } connects (if needed), runs block with the toolkit as the receiver, and always
disconnects afterward, including when block throws. Prefer it over manual connect() /
disconnect() pairs unless you specifically need the connection to outlive one call.
val NtagToolkit.uid: ByteArray? // Tag.getId(), typically 7 bytes for NTAG21x
val NtagToolkit.variant: NtagVariant // NTAG213 / NTAG215 / NTAG216, detected by create()
val NtagToolkit.cachedNdefMessage: NdefMessage? // from the last discovery, no tag round-tripNtagVariant also exposes label, pageCount, and totalMemoryBytes if you want to show
capacity in a UI without doing your own lookup table:
enum class NtagVariant(val label: String, val pageCount: Int, val totalMemoryBytes: Int) {
NTAG213(label = "NTAG213", pageCount = 45, totalMemoryBytes = 144),
NTAG215(label = "NTAG215", pageCount = 135, totalMemoryBytes = 504),
NTAG216(label = "NTAG216", pageCount = 231, totalMemoryBytes = 888),
}suspend fun NtagToolkit.readNdefMessage(): NdefMessage
suspend fun NtagToolkit.writeNdefMessage(ndefMessage: NdefMessage)
suspend fun NtagToolkit.writeEmptyNdefMessage()toolkit.use {
val message = readNdefMessage()
val record = NdefRecord.createTextRecord("en", "Hello, tag!")
writeNdefMessage(NdefMessage(arrayOf(record)))
// Later, wipe it:
writeEmptyNdefMessage()
}readNdefMessage()reads user memory inFAST_READchunks (clamped to the variant's real data boundary, so it never reads into the config pages), decodes the TLV wrapper, and parses the result as anNdefMessage. ThrowsNotNdefMessageif what's stored isn't valid NDEF (e.g. a blank/erased tag).writeNdefMessage()TLV-encodes and 4-byte-pads your message, then writes it page by page. ThrowsNdefMessageTooBigif the encoded message exceeds the variant's user-memory capacity. Checktoolkit.variant.totalMemoryBytesup front if you want to validate before attempting a write.writeEmptyNdefMessage()writes a zero-length NDEF TLV, which is the standard way to mark a Type 2 tag as blank without erasing every page.
suspend fun NtagToolkit.setPassword(password: ByteArray?) // 4 bytes, or null to disable
suspend fun NtagToolkit.authenticate(password: ByteArray) // 4 bytes
suspend fun NtagToolkit.isPasswordProtected(): Boolean
suspend fun NtagToolkit.setPasswordRequiredForReading(required: Boolean)
suspend fun NtagToolkit.isPasswordRequiredForReading(): BooleanNTAG21x password protection has two independent layers, and NtagToolkit keeps them as two separate calls rather than one boolean-flag API:
- Write protection:
setPassword(pwd)sets the tag'sPWDand movesAUTH0down to lock everything from that page onward, so writes (and, once you enable it below, reads) requireauthenticate()first.setPassword(null)restores the factory-default password and unlocksAUTH0again. - Read protection: off by default even on a password-protected tag (NTAG21x only gates
writes by default).
setPasswordRequiredForReading(true)flips the config ACCESS byte'sPROTbit so reads are gated too;falseclears it.
toolkit.use {
setPassword("1234".toByteArray()) // write-protect
setPasswordRequiredForReading(true) // also read-protect
// On a later session, against the same tag:
authenticate("1234".toByteArray())
val message = readNdefMessage() // now permitted
setPasswordRequiredForReading(false)
setPassword(null) // remove protection entirely
}Both status checks work without authenticating first, even on a protected tag: NTAG21x NAKs
an unauthenticated read of the config page itself once AUTH0 locks it, and NtagToolkit treats
that NAK as proof of protection rather than surfacing it as an error.
if (toolkit.isPasswordProtected()) {
// prompt the user for a password before calling authenticate()
}authenticate() and setPassword() throw InvalidPasswordLength for anything other than exactly
4 bytes. A failed authenticate() doesn't throw by itself: per the datasheet, an incorrect
password simply means every subsequent protected command NAKs, so check the result of your next
operation (or compare against a known PACK if you need an explicit yes/no).
suspend fun NtagToolkit.transceive(command: ByteArray): ByteArrayAn escape hatch for anything the typed API doesn't cover yet (vendor-specific commands, raw
READ/WRITE at an arbitrary page, etc.); it goes through the same connection guard, mutex, and
Dispatchers.IO hop as every other call, so it's safe to interleave with typed calls on the same
instance.
Every operation throws a subtype of the sealed NtagToolkitException instead of a raw
IOException/FormatException/NPE, so you can exhaustively when over failure modes:
sealed class NtagToolkitException(message: String?, cause: Throwable?) : Exception(message, cause) {
class NotConnected : NtagToolkitException // no NfcA on this tag, or not connected
class UnsupportedTag : NtagToolkitException // GET_VERSION didn't match NTAG213/215/216
class InvalidPasswordLength : NtagToolkitException // password wasn't exactly 4 bytes
class NdefMessageTooBig : NtagToolkitException // encoded message exceeds user memory
class NotNdefMessage : NtagToolkitException // tag data isn't valid NDEF
class Error(message: String?, cause: Throwable?) : NtagToolkitException // I/O / everything else
}try {
toolkit.use { writeNdefMessage(message) }
} catch (e: NtagToolkitException.NdefMessageTooBig) {
// message.toByteArray().size vs. toolkit.variant.totalMemoryBytes
} catch (e: NtagToolkitException.Error) {
// tag moved out of range mid-write, etc.; e.cause has the underlying IOException
}Every public suspend function acquires an internal Mutex (and hops to Dispatchers.IO) exactly
once per call, so calls issued concurrently from multiple coroutines on one shared NtagToolkit
instance are serialized rather than interleaved on the wire, which matters because NFC tag I/O is
inherently sequential, and two overlapping transceive() calls mid-write could otherwise corrupt
the tag. Compound operations like setPassword(), which issue several raw commands, take the lock
once for the whole operation, not once per command.
This guarantee is per-instance, not global: create a fresh NtagToolkit for each newly discovered
Tag rather than trying to reuse one across separate tag sessions.
sample/ is a Compose demo app that exercises the whole library end to end against real
hardware: scan a tag, inspect it, edit its NDEF content, manage password/read protection.
See sample/README.md for the full screen-by-screen walkthrough,
screenshots, architecture (data/domain/platform/ui, Koin, localization), and its test suite.
-
lib/src/test: plain JVM + Robolectric unit tests, no emulator or physical tag required:TlvCodecTest: pure JUnit tests for the TLV encode/decode/pad functions.NtagSpecTest: pure JUnit tests forGET_VERSION→ variant matching.NtagToolkitTest: the full public API exercised againstFakeNtag, a datasheet-accurate in-memory NTAG21x simulator (parametrized per variant), withNfcA/Ndefmocked via Mockito Kotlin under Robolectric. Covers connection guards,use()semantics, password set/clear/authenticate, read-protection toggling, NDEF round-trips (including the too-big case), and concurrent-call serialization.
./gradlew :lib:test -
sample: see sample/README.md#testing.
Commands, page addresses, and access-control semantics are cross-checked directly against the NXP
NTAG213/215/216 datasheet, Rev 3.2 (Fig. 5/6/7 for memory organization, Table 28 for the
GET_VERSION response), not implemented from memory or from secondary sources.