Read PAN from contactless EMV cards on Android in Kotlin. BankCardNFCReader is a lightweight Android NFC credit card reader library that extracts the card number (PAN/DPAN), card type (Visa, Mastercard, Amex, Discover, UnionPay, JCB, Mir), cardholder name, AID, and detects Google Wallet, Apple Pay, Samsung Pay, Garmin Pay and Fitbit Pay vs a physical card — all via standard EMV tags over NFC.
- Features
- Why this library?
- Use cases
- Installation
- Quick Start (View)
- Quick Start (Jetpack Compose)
- API Reference
- Payment Source Detection
- Cardholder Name & Offline PIN Tries
- AID & Friendly Names
- AndroidManifest & Permissions
- ProGuard / R8
- Security & PCI-DSS
- Requirements
- Advanced Usage
- Testing
- FAQ
- Roadmap
- Changelog
- Contributing
- License
- Read card numbers from NFC-enabled credit/debit cards (PAN / DPAN)
- Cardholder name from EMV tag
5F20(when exposed) - AID + friendly name via
AidLabels(Visa Credit/Debit, Mastercard, Amex, …) - Offline PIN tries remaining via tag
9F17 - Multi-brand: Visa, Mastercard, American Express, Discover, UnionPay, JCB, Mir
- Payment Source Detection: physical card vs Google Wallet, Apple Pay, Samsung Pay, Garmin Pay, Fitbit Pay
- Form Factor (
9F6E) + Token Requestor ID (9F19) parsing for wallet identification - Read-only: cannot make payments or modify card data
- Kotlin coroutines suspend API
- Lightweight: depends only on
androidx.coreandkotlinx-coroutines-android
| Library | Lang | Min SDK | EMV PAN | Cardholder name (5F20) | Wallet vs physical (9F6E/9F19) | Coroutines | Last update | License |
|---|---|---|---|---|---|---|---|---|
| Arm63/BankCardNFCReader (this) | Kotlin | 21 | yes | yes | yes | yes | active | MIT |
| devnied/EMV-NFC-Paycard-Enrollment | Java | 14 | yes | yes | no | no | low | Apache-2.0 |
| pro100svitlo/CreditCardNfcReader | Java | 14 | yes | partial | no | no | low | Apache-2.0 |
| sasc999/javaemvreader | Java | n/a | yes | yes | no | no | archive | LGPL |
| balysv/android-card-form | Java | 16 | n/a (manual UI) | n/a | n/a | no | low | Apache-2.0 |
| stripe/stripe-android | Kotlin | 21 | tokenization SDK, no raw NFC PAN | n/a | n/a | yes | active | MIT |
Differentiators:
- Kotlin-first, coroutine-first EMV NFC Android library.
- Surfaces EMV
9F6EForm Factor Indicator and9F19Token Requestor ID for Google Wallet detection on Android, plus Apple Pay / Samsung Pay / Garmin Pay / Fitbit Pay. - Surfaces offline PIN tries (
9F17) and cardholder name (5F20) when the card exposes them. - AID friendly-name resolver (
AidLabels) for Visa, Mastercard, Amex, Discover, UnionPay, JCB, Mir.
- KYC & onboarding — prefill card brand and last-4 in a fintech sign-up flow.
- Bank apps — card-on-file enrollment without manual PAN entry.
- Expense / receipt apps — tag a transaction to the physical card it was paid with.
- P2P payment apps — account funding via tap-to-add-card.
- Loyalty / membership apps — link a customer's contactless card as a soft identifier (last-4 + AID) without storing the full PAN.
- Internal tools / fraud ops — distinguish a scanned plastic card from a tokenized wallet (DPAN) for risk scoring.
- Hardware integrations — kiosk / POS companion apps reading a customer card on an Android tablet.
Add JitPack to your root settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}Add the dependency to your app's build.gradle.kts:
dependencies {
implementation("com.github.Arm63:BankCardNFCReader:1.1.4")
}Latest version is shown on the JitPack badge above. Replace
1.1.4if a newer release is published.
class MainActivity : AppCompatActivity() {
private val cardReader = EmvCardReader()
private var nfcAdapter: NfcAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
}
override fun onResume() {
super.onResume()
nfcAdapter?.enableReaderMode(
this,
{ tag -> handleNfcTag(tag) },
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B,
null
)
}
override fun onPause() {
super.onPause()
nfcAdapter?.disableReaderMode(this)
}
private fun handleNfcTag(tag: Tag) {
lifecycleScope.launch {
when (val result = cardReader.readCard(tag)) {
is CardData.Success -> {
val cardNumber = result.maskedPan // "4111 **** **** 1111"
val cardType = result.cardType // CardType.VISA
val source = result.paymentSource // PaymentSource.PHYSICAL_CARD
val isWallet = result.isTokenizedWallet // false
val owner = result.maskedOwnerName() // "A**** A****" or null
val aid = result.aidDisplayName // "Visa Credit/Debit" or null
val pinTries = result.pinTriesRemaining // 3 or null
if (isWallet) showWalletCard(cardNumber, source.displayName)
else showPhysicalCard(cardNumber, cardType.displayName)
}
is CardData.Error -> showError(result.message)
}
}
}
}@Composable
fun CardReaderScreen() {
val context = LocalContext.current
val activity = context as Activity
var cardData by remember { mutableStateOf<CardData?>(null) }
val cardReader = remember { EmvCardReader() }
DisposableEffect(Unit) {
val nfcAdapter = NfcAdapter.getDefaultAdapter(activity)
nfcAdapter?.enableReaderMode(
activity,
{ tag ->
CoroutineScope(Dispatchers.Main).launch {
cardData = cardReader.readCard(tag)
}
},
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B,
null
)
onDispose { nfcAdapter?.disableReaderMode(activity) }
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (val result = cardData) {
is CardData.Success -> Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (result.isTokenizedWallet) "📱 ${result.paymentSource.displayName}"
else "💳 Physical Card",
fontSize = 18.sp
)
Text(result.cardType.displayName, fontSize = 24.sp)
Text(result.maskedPan, fontSize = 20.sp, fontFamily = FontFamily.Monospace)
result.maskedOwnerName()?.let { Text(it, fontSize = 14.sp) }
result.aidDisplayName?.let { Text(it, fontSize = 12.sp, color = Color.Gray) }
result.pinTriesRemaining?.let { Text("PIN tries left: $it", fontSize = 12.sp) }
if (result.isTokenizedWallet) Text("Tokenized (DPAN)", fontSize = 12.sp, color = Color.Gray)
}
is CardData.Error -> Text("❌ ${result.message}", color = Color.Red)
null -> Text("Tap your card to read", fontSize = 18.sp)
}
}
}| Property | Type | Example | Description |
|---|---|---|---|
pan |
String |
"4111111111111111" |
Raw PAN. DPAN (Device PAN, tokenized) for digital wallets. |
formattedPan |
String |
"4111 1111 1111 1111" |
PAN grouped in 4-digit blocks. |
maskedPan |
String |
"4111 **** **** 1111" |
Display-safe masked PAN. |
cardType |
CardType |
CardType.VISA |
Detected brand. |
paymentSource |
PaymentSource |
PaymentSource.GOOGLE_WALLET |
Resolved payment source (physical card vs wallet). |
sourceDetectionResult |
CardSourceDetector.DetectionResult? |
– | Form Factor bytes, Token Requestor ID, confidence, debug. |
cardholderName |
String? |
"ASATRYAN/ARMEN" |
EMV tag 5F20. Often null on contactless for privacy. |
aid |
String? |
"A0000000031010" |
Selected AID (tag 4F), uppercase hex. |
aidDisplayName |
String? |
"Visa Credit/Debit" |
Friendly label resolved by AidLabels. null if unknown. |
pinTriesRemaining |
Int? |
3 |
Offline PIN Try Counter (tag 9F17). null if not exposed. |
isTokenizedWallet |
Boolean |
true |
paymentSource.isDigitalWallet. |
isPhysicalCard |
Boolean |
false |
paymentSource.isPhysicalCard. |
| Method | Returns | Description |
|---|---|---|
maskedOwnerName() |
String? |
Privacy-safe display form of cardholderName, e.g. "A**** A****". Splits on whitespace and /, keeps first letter of each token. Returns null when name is missing or blank. |
enum class PaymentSource {
PHYSICAL_CARD,
GOOGLE_WALLET,
SAMSUNG_PAY,
APPLE_PAY,
GARMIN_PAY,
FITBIT_PAY,
MOBILE_WALLET,
OTHER_WALLET,
UNKNOWN
}| Card | Status | Detected by |
|---|---|---|
| Visa | ✅ | Starts with 4 |
| Mastercard | ✅ | Starts with 51-55 or 2221-2720 |
| American Express | ✅ | Starts with 34 or 37 |
| Discover | ✅ | Starts with 6011, 65, or 644-649 |
| UnionPay | ✅ | Starts with 62 |
| JCB | ✅ | Starts with 35 |
| Mir | ✅ | Starts with 220 |
| Code | When |
|---|---|
UNSUPPORTED_CARD |
Card doesn't support contactless |
PPSE_NOT_FOUND |
No payment app on card |
AID_NOT_FOUND |
No supported payment application found |
GPO_FAILED |
GET PROCESSING OPTIONS command failed |
PAN_NOT_FOUND |
Could not read card number |
TAG_LOST |
Card removed too quickly |
COMMUNICATION_ERROR |
NFC read failed |
The library automatically detects whether the scanned card is a physical plastic card or a digital wallet using EMV tags from the card response:
| EMV Tag | Name | Purpose |
|---|---|---|
| 9F6E | Form Factor Indicator | Identifies device type (card vs mobile) |
| 9F19 | Token Requestor ID | Identifies specific wallet provider |
| 50 | Application Label | May contain wallet identifiers |
1. Check Token Requestor ID (9F19)
- Known ID → specific wallet (HIGH confidence)
- Unknown ID → other wallet (MEDIUM confidence)
2. Check Form Factor Indicator (9F6E)
- Byte 2 Bit 8 = 0 → physical card (not connected)
- Byte 2 Bit 8 = 1 → mobile wallet (network connected)
3. Check Application Label (50)
- Contains wallet keywords → infer wallet type
| Payment source | Detection method | Confidence |
|---|---|---|
| Physical Card | Form Factor (not network-connected) | HIGH |
| Google Wallet | Token Requestor ID | HIGH |
| Samsung Pay | Token Requestor ID | HIGH |
| Apple Pay | Token Requestor ID | HIGH |
| Garmin Pay | Token Requestor ID | HIGH |
| Fitbit Pay | Token Requestor ID | HIGH |
| Mobile Wallet | Form Factor (network-connected, unknown provider) | HIGH |
when (result) {
is CardData.Success -> result.sourceDetectionResult?.let { detection ->
Log.d("Detection", "Source: ${detection.source}")
Log.d("Detection", "Confidence: ${detection.confidence}")
Log.d("Detection", "Form Factor: ${detection.formFactorIndicator?.toHex()}")
Log.d("Detection", "Token Requestor ID: ${detection.tokenRequestorId}")
Log.d("Detection", "Debug Info: ${detection.debugInfo}")
}
else -> Unit
}Some EMV cards expose the cardholder name (tag 5F20) and the offline PIN Try Counter (tag 9F17) during contactless reads. Both are optional per card / issuer profile and are frequently null on tap.
when (val r = cardReader.readCard(tag)) {
is CardData.Success -> {
r.cardholderName // "ASATRYAN/ARMEN" or null
r.maskedOwnerName() // "A**** A****" or null
r.pinTriesRemaining // 3 or null
}
else -> Unit
}AidLabels.displayName(aidHex) maps RID/AID prefixes to a friendly name:
| AID prefix | Friendly name |
|---|---|
A0000000031010, A0000000032010 |
Visa Credit/Debit |
A000000003* |
Visa |
A0000000041010, A0000000043060, A0000000043010 |
Mastercard |
A000000025* |
American Express |
A0000001523010 |
Discover |
A000000333* |
UnionPay |
A0000000651010 |
JCB |
A0000006581010 |
Mir |
You can also call AidLabels.displayName(aidHex) directly if you have an AID hex string from your own reader code.
The library declares NFC permission automatically. To require NFC hardware on Play Store:
<manifest>
<uses-feature android:name="android.hardware.nfc" android:required="true" />
</manifest>The library ships consumer-rules.pro so no extra rules are required. If you proxy results through reflection or serialization (Gson / Moshi / kotlinx.serialization), keep the data classes:
-keep class com.emvreader.nfc.CardData$Success { *; }
-keep class com.emvreader.nfc.CardSourceDetector$DetectionResult { *; }
-keepclassmembers enum com.emvreader.nfc.** { *; }This library only reads publicly available card data:
- ✅ Card number (PAN / DPAN for wallets)
- ✅ Card type detection
- ✅ Payment source detection
- ✅ Cardholder name (when exposed)
- ❌ Cannot read CVV/CVC
- ❌ Cannot read PIN
- ❌ Cannot make transactions
- ❌ Cannot clone cards
Tokenized wallets: the PAN returned from digital wallets is a DPAN (Device PAN), tokenized and device-bound. It cannot be used on other devices.
Handle card numbers according to PCI-DSS in your application. The library does not transmit, store, or persist any card data.
- Android 5.0+ (API 21+)
- Device with NFC hardware
- Kotlin 1.8+
val reader = EmvCardReader(
config = ReaderConfig(timeoutMs = 10000)
)val detectionResult = CardSourceDetector.detect(tlvData)
Log.d("Detection", "Source: ${detectionResult.source}")
Log.d("Detection", "Confidence: ${detectionResult.confidence}")
Log.d("Detection", "Is Wallet: ${detectionResult.source.isDigitalWallet}")Any contactless Visa, Mastercard, etc. card. Detection returns PaymentSource.PHYSICAL_CARD.
- Google Wallet — add a card to Google Wallet, tap phone to test device.
- Apple Pay — add a card to Apple Pay, tap iPhone to test device.
- Samsung Pay — add a card to Samsung Pay, tap Samsung phone to test device.
Without Token Requestor ID, wallets are detected as MOBILE_WALLET (provider unknown).
NFC card emulation requires real hardware. Emulators do not work for wallet testing.
Add the JitPack dependency, instantiate EmvCardReader, call enableReaderMode on NfcAdapter, and pass the Tag to cardReader.readCard(tag) from a coroutine. See Quick Start.
No. CVV/CVC is not in the contactless EMV response. This library cannot read it.
Yes. The library inspects EMV tag 9F6E (Form Factor Indicator) and 9F19 (Token Requestor ID) and returns PaymentSource.GOOGLE_WALLET / PHYSICAL_CARD / MOBILE_WALLET.
PAN is the real card number embossed on the plastic. DPAN is a tokenized device-bound number returned by Google Wallet, Apple Pay, Samsung Pay etc. This library returns whichever the card chose to expose; isTokenizedWallet tells you which.
The Android device reads whatever the iPhone presents in card-emulation mode. The PAN you receive is the iPhone's DPAN, and paymentSource resolves to MOBILE_WALLET (or APPLE_PAY if Apple's Token Requestor ID is present in 9F19).
API 21 (Android 5.0). Min SDK 21, Kotlin, coroutines.
Sometimes. EMV tag 5F20 is optional on contactless and many issuers strip it for privacy. When present it is exposed as cardholderName, with maskedOwnerName() for display.
Read the card and check pinTriesRemaining (EMV tag 9F17). It is null when the card does not expose it on contactless.
The library is read-only and does not transmit, store, or persist PAN. Whatever your app does with the returned PAN/DPAN is your PCI scope, not the library's. See Security & PCI-DSS.
Yes. The library only depends on androidx.core and kotlinx-coroutines-android.
Yes. The API is Kotlin but interop-friendly. Use CardData.Success getters from Java; suspend readCard is callable via kotlinx-coroutines-jvm interop helpers.
No. NFC card emulation between two real devices is required for wallet testing. Use real hardware.
- Track 2 equivalent data parsing (
57) - Application Expiration Date (
5F24) exposure - Application Currency Code (
9F42) - Maven Central publishing in addition to JitPack
- CI release workflow with GitHub Actions
- Read cardholder name from EMV tag
5F20and exposecardholderName+maskedOwnerName()onCardData.Success - Show remaining offline PIN tries (
9F17) viapinTriesRemaining - Expose selected AID (
4F) and friendly name viaaid+aidDisplayName(backed byAidLabels) - Internal: rename app package to
com.emvreader.bankcardreader
- Removed unused
NfcCardManagerclass (useEmvCardReaderdirectly) - Removed all debug logging for production builds
- Simplified API with single reader class
- Updated documentation and examples
- Stabilized on Android SDK 35 with androidx.core 1.15.0
- Improved dependency compatibility and stability
- Updated dependencies for better compatibility
- Minor bug fixes and improvements
- Added payment source detection (physical cards vs digital wallets)
- Support for detecting Google Wallet, Apple Pay, Samsung Pay, and more
- Enhanced card reading with tokenized wallet detection
- Improved EMV tag parsing for Form Factor Indicator and Token Requestor ID
- Initial release with NFC card reading support
- Multi-brand support: Visa, Mastercard, Amex, Discover, UnionPay, JCB, Mir
- Simple API with
EmvCardReader
- Open an issue first for non-trivial work.
- See CONTRIBUTING.md for branch / commit / test conventions.
- By contributing you agree to the Code of Conduct.
- Security issues go through SECURITY.md, not public issues.
git clone https://github.com/Arm63/BankCardNFCReader.git
cd BankCardNFCReader
./gradlew :android-bank-card-reader:assemble :android-bank-card-reader:testMIT License - Copyright (c) 2025 Armen Asatryan
Keywords: android nfc credit card reader library, read PAN from contactless card kotlin, EMV nfc android library, google wallet detection android, apple pay detection android, samsung pay nfc detection, dpan vs pan android, kotlin coroutines nfc reader, emv tag 5f20 cardholder name, emv tag 9f17 pin try counter, emv tag 9f6e form factor indicator, emv tag 9f19 token requestor id, visa mastercard nfc kotlin, jitpack android library

