Skip to content

Repository files navigation

NtagToolkit

JitPack

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

Contents

Why NtagToolkit

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.

Install

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.

Getting started

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.

API reference

Connecting

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.

Identity

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-trip

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

Reading and writing NDEF

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 in FAST_READ chunks (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 an NdefMessage. Throws NotNdefMessage if 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. Throws NdefMessageTooBig if the encoded message exceeds the variant's user-memory capacity. Check toolkit.variant.totalMemoryBytes up 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.

Password protection

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(): Boolean

NTAG21x password protection has two independent layers, and NtagToolkit keeps them as two separate calls rather than one boolean-flag API:

  1. Write protection: setPassword(pwd) sets the tag's PWD and moves AUTH0 down to lock everything from that page onward, so writes (and, once you enable it below, reads) require authenticate() first. setPassword(null) restores the factory-default password and unlocks AUTH0 again.
  2. Read protection: off by default even on a password-protected tag (NTAG21x only gates writes by default). setPasswordRequiredForReading(true) flips the config ACCESS byte's PROT bit so reads are gated too; false clears 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).

Raw commands

suspend fun NtagToolkit.transceive(command: ByteArray): ByteArray

An 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.

Errors

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
}

Concurrency

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 app

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.

Testing

  • 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 for GET_VERSION → variant matching.
    • NtagToolkitTest: the full public API exercised against FakeNtag, a datasheet-accurate in-memory NTAG21x simulator (parametrized per variant), with NfcA/Ndef mocked 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.

Datasheet

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.

License

Apache License 2.0.

About

Kotlin library for reading, writing, and password-protecting NTAG213/215/216 NFC tags on Android

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages