Skip to content

Commit 37bb0f6

Browse files
author
armenasatryan
committed
Expose application expiration date (EMV tag 5F24)
Adds ExpiryDate(year, month) with isExpired(), displayMmYy(), displayMmYyyy(). Wired into CardData.Success and the sample app's SuccessContent (red label when expired). GET DATA 5F24 fallback for cards that omit the tag from AFL records. Bumps library to 1.1.5. Also adds Compose @Preview composables for every screen state (idle, scanning, reading, success variants, error, NFC disabled, NFC not available) and refreshes the physical-card screenshot.
1 parent de45d55 commit 37bb0f6

10 files changed

Lines changed: 394 additions & 16 deletions

File tree

NFC_CARD_READER_README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,11 @@ Tests cover:
135135
## Known Limitations
136136

137137
1. **Some cards don't expose PAN via NFC** - security feature on some issuers
138-
2. **CVV/expiry not readable** - intentionally excluded for security
139-
3. **Cardholder name often unavailable** via contactless
140-
4. **Virtual cards** (Google Pay, Apple Pay) - different protocol
141-
5. **Chip-only cards** - no contactless antenna
138+
2. **CVV not readable** - not present in contactless EMV response
139+
3. **Expiry (`5F24`)** - exposed by most physical cards; many wallets strip it
140+
4. **Cardholder name often unavailable** via contactless
141+
5. **Virtual cards** (Google Pay, Apple Pay) - different protocol
142+
6. **Chip-only cards** - no contactless antenna
142143

143144
## Security Notes
144145

README.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
- **Cardholder name** from EMV tag `5F20` (when exposed)
4646
- **AID + friendly name** via `AidLabels` (Visa Credit/Debit, Mastercard, Amex, …)
4747
- **Offline PIN tries remaining** via tag `9F17`
48+
- **Card expiry (`5F24`)** with `isExpired()` helper and `MM/YY` / `MM/YYYY` display
4849
- **Multi-brand**: Visa, Mastercard, American Express, Discover, UnionPay, JCB, Mir
4950
- **Payment Source Detection**: physical card vs Google Wallet, Apple Pay, Samsung Pay, Garmin Pay, Fitbit Pay
5051
- **Form Factor (`9F6E`) + Token Requestor ID (`9F19`) parsing** for wallet identification
@@ -98,11 +99,11 @@ Add the dependency to your app's `build.gradle.kts`:
9899

99100
```kotlin
100101
dependencies {
101-
implementation("com.github.Arm63:BankCardNFCReader:1.1.4")
102+
implementation("com.github.Arm63:BankCardNFCReader:1.1.5")
102103
}
103104
```
104105

105-
> Latest version is shown on the JitPack badge above. Replace `1.1.4` if a newer release is published.
106+
> Latest version is shown on the JitPack badge above. Replace `1.1.5` if a newer release is published.
106107
107108
## Quick Start (View)
108109

@@ -142,6 +143,8 @@ class MainActivity : AppCompatActivity() {
142143
val owner = result.maskedOwnerName() // "A**** A****" or null
143144
val aid = result.aidDisplayName // "Visa Credit/Debit" or null
144145
val pinTries = result.pinTriesRemaining // 3 or null
146+
val expiry = result.expiryDate?.displayMmYy() // "12/27" or null
147+
val expired = result.expiryDate?.isExpired() == true
145148

146149
if (isWallet) showWalletCard(cardNumber, source.displayName)
147150
else showPhysicalCard(cardNumber, cardType.displayName)
@@ -191,6 +194,10 @@ fun CardReaderScreen() {
191194
result.maskedOwnerName()?.let { Text(it, fontSize = 14.sp) }
192195
result.aidDisplayName?.let { Text(it, fontSize = 12.sp, color = Color.Gray) }
193196
result.pinTriesRemaining?.let { Text("PIN tries left: $it", fontSize = 12.sp) }
197+
result.expiryDate?.let {
198+
val color = if (it.isExpired()) Color.Red else Color.Unspecified
199+
Text("Expires: ${it.displayMmYy()}", fontSize = 12.sp, color = color)
200+
}
194201
if (result.isTokenizedWallet) Text("Tokenized (DPAN)", fontSize = 12.sp, color = Color.Gray)
195202
}
196203
is CardData.Error -> Text("${result.message}", color = Color.Red)
@@ -216,6 +223,7 @@ fun CardReaderScreen() {
216223
| `aid` | `String?` | `"A0000000031010"` | Selected AID (tag `4F`), uppercase hex. |
217224
| `aidDisplayName` | `String?` | `"Visa Credit/Debit"` | Friendly label resolved by `AidLabels`. `null` if unknown. |
218225
| `pinTriesRemaining` | `Int?` | `3` | Offline PIN Try Counter (tag **`9F17`**). `null` if not exposed. |
226+
| `expiryDate` | `ExpiryDate?` | `ExpiryDate(2027, 12)` | Application Expiration Date (tag **`5F24`**). `null` if card omits it. |
219227
| `isTokenizedWallet` | `Boolean` | `true` | `paymentSource.isDigitalWallet`. |
220228
| `isPhysicalCard` | `Boolean` | `false` | `paymentSource.isPhysicalCard`. |
221229

@@ -225,6 +233,16 @@ fun CardReaderScreen() {
225233
|---|---|---|
226234
| `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. |
227235

236+
### `ExpiryDate`
237+
238+
```kotlin
239+
data class ExpiryDate(val year: Int, val month: Int) {
240+
fun displayMmYy(): String // "12/27"
241+
fun displayMmYyyy(): String // "12/2027"
242+
fun isExpired(now: Calendar = Calendar.getInstance()): Boolean
243+
}
244+
```
245+
228246
### `PaymentSource` enum
229247

230248
```kotlin
@@ -466,14 +484,19 @@ No. NFC card emulation between two real devices is required for wallet testing.
466484
## Roadmap
467485

468486
- [ ] Track 2 equivalent data parsing (`57`)
469-
- [ ] Application Expiration Date (`5F24`) exposure
470487
- [ ] Application Currency Code (`9F42`)
471488
- [ ] Maven Central publishing in addition to JitPack
472489
- [ ] CI release workflow with GitHub Actions
473490

474491
## Changelog
475492

476-
### v1.1.4 (Current)
493+
### v1.1.5 (Current)
494+
- Expose **Application Expiration Date** (EMV tag `5F24`) as `expiryDate: ExpiryDate?` on `CardData.Success`
495+
- `ExpiryDate.isExpired()`, `displayMmYy()`, `displayMmYyyy()` helpers
496+
- GET DATA `5F24` fallback when AFL records omit the tag
497+
- Sample app shows expiry on detected card (red when expired)
498+
499+
### v1.1.4
477500
- Read **cardholder name** from EMV tag `5F20` and expose `cardholderName` + `maskedOwnerName()` on `CardData.Success`
478501
- Show **remaining offline PIN tries** (`9F17`) via `pinTriesRemaining`
479502
- Expose selected **AID** (`4F`) and friendly name via `aid` + `aidDisplayName` (backed by `AidLabels`)
@@ -532,4 +555,4 @@ MIT License - Copyright (c) 2025 Armen Asatryan
532555

533556
---
534557

535-
**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
558+
**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 5f24 expiry, emv tag 9f6e form factor indicator, emv tag 9f19 token requestor id, visa mastercard nfc kotlin, jitpack android library

android-bank-card-reader/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ afterEvaluate {
5252

5353
groupId = "com.github.Arm63"
5454
artifactId = "BankCardNFCReader"
55-
version = "1.1.4"
55+
version = "1.1.5"
5656

5757
pom {
5858
name.set("Bank Card NFC Reader")

android-bank-card-reader/src/main/java/com/emvreader/nfc/CardData.kt

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,31 @@
11
package com.emvreader.nfc
22

3+
import java.util.Calendar
4+
5+
/**
6+
* Card application expiration date from EMV tag `5F24`.
7+
*
8+
* @property year Four-digit Gregorian year (e.g. 2027).
9+
* @property month Month, 1..12.
10+
*/
11+
data class ExpiryDate(val year: Int, val month: Int) {
12+
/** Display as `MM/YY` (e.g. `12/27`). */
13+
fun displayMmYy(): String = "%02d/%02d".format(month, year % 100)
14+
15+
/** Display as `MM/YYYY` (e.g. `12/2027`). */
16+
fun displayMmYyyy(): String = "%02d/%04d".format(month, year)
17+
18+
/**
19+
* True if [now] is after the expiry month. EMV cards expire at end of the printed month,
20+
* so a card with expiry `12/2025` is valid through 2025-12-31.
21+
*/
22+
fun isExpired(now: Calendar = Calendar.getInstance()): Boolean {
23+
val nowYear = now.get(Calendar.YEAR)
24+
val nowMonth = now.get(Calendar.MONTH) + 1
25+
return nowYear > year || (nowYear == year && nowMonth > month)
26+
}
27+
}
28+
329
/**
430
* Human-readable labels for common RID + AID prefixes (hex, uppercase).
531
*/
@@ -42,6 +68,7 @@ sealed class CardData {
4268
* @property cardholderName Cardholder name from EMV tag 5F20 when present. Often **null** on contactless for privacy.
4369
* @property aid Selected payment application identifier (tag `4F`), uppercase hex without spaces.
4470
* @property pinTriesRemaining Offline PIN tries left (tag `9F17`) when the card exposes it; often **null** on contactless.
71+
* @property expiryDate Application expiration date from tag `5F24` (`MM/YY`). **null** when card omits it.
4572
*/
4673
data class Success(
4774
val pan: String,
@@ -52,7 +79,8 @@ sealed class CardData {
5279
val sourceDetectionResult: CardSourceDetector.DetectionResult? = null,
5380
val cardholderName: String? = null,
5481
val aid: String? = null,
55-
val pinTriesRemaining: Int? = null
82+
val pinTriesRemaining: Int? = null,
83+
val expiryDate: ExpiryDate? = null
5684
) : CardData() {
5785

5886
/**

android-bank-card-reader/src/main/java/com/emvreader/nfc/EmvCardReader.kt

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ class EmvCardReader(
7676
collectedTlvData[TlvParser.TAG_TRACK1]?.value?.let { v ->
7777
emvLog("[$phase] 56 Track1 len=${v.size} hex=${v.toHex().hexPreview()}")
7878
}
79+
collectedTlvData[TlvParser.TAG_EXPIRY]?.value?.let { v ->
80+
val parsed = TlvParser.extractExpiry(collectedTlvData)
81+
emvLog(
82+
"[$phase] 5F24 raw len=${v.size} hex=${v.toHex().hexPreview()} " +
83+
"parsed=${parsed?.let { "${it.year}-${"%02d".format(it.month)}" } ?: "null"}"
84+
)
85+
}
7986
}
8087

8188
private fun logApduResult(label: String, command: ByteArray, response: ByteArray) {
@@ -130,10 +137,14 @@ class EmvCardReader(
130137
emvLog("try AID=${aid.toHex()}")
131138
val result = tryReadWithAid(isoDep, aid)
132139
if (result is CardData.Success) {
140+
val expStr = result.expiryDate?.let {
141+
"${it.displayMmYy()} expired=${it.isExpired()}"
142+
} ?: "null"
133143
emvLog(
134144
"readCard: success last4=${result.pan.takeLast(4)} " +
135145
"aid=${result.aid ?: "null"} " +
136146
"pinTries=${result.pinTriesRemaining ?: "null"} " +
147+
"expiry=$expStr " +
137148
"cardholderName=${result.cardholderName?.let { "\"$it\"" } ?: "null"}"
138149
)
139150
return@withContext result
@@ -288,7 +299,8 @@ class EmvCardReader(
288299

289300
mergeCardholderFromGetData(isoDep)
290301
mergePinTryCounterFromGetData(isoDep)
291-
logCollectedTlv("after_GET_DATA_5F20_9F17")
302+
mergeExpiryFromGetData(isoDep)
303+
logCollectedTlv("after_GET_DATA_5F20_9F17_5F24")
292304

293305
foundPan?.let {
294306
val name = TlvParser.extractCardholderName(collectedTlvData)
@@ -407,6 +419,34 @@ class EmvCardReader(
407419
emvLog("GET_DATA 9F17: exhausted or unsupported")
408420
}
409421

422+
/** GET DATA for tag 5F24 (expiry); merges into [collectedTlvData] on success. */
423+
private fun mergeExpiryFromGetData(isoDep: IsoDep) {
424+
if (TlvParser.extractExpiry(collectedTlvData) != null) {
425+
emvLog("GET_DATA 5F24: skip (already in TLV map)")
426+
return
427+
}
428+
for (cla in intArrayOf(0x80, 0x00)) {
429+
try {
430+
val cmd = ApduBuilder.getData(0x5F, 0x24, cla)
431+
val resp = isoDep.transceive(cmd)
432+
logApduResult("GET_DATA 5F24 CLA=${"%02X".format(cla)}", cmd, resp)
433+
if (!resp.isSuccess()) continue
434+
val data = resp.getData()
435+
if (data.isEmpty()) continue
436+
val parsed = TlvParser.parse(data)
437+
emvLog("GET_DATA 5F24 parsed tags=${parsed.keys.sorted().joinToString(",")}")
438+
collectedTlvData.putAll(parsed)
439+
if (TlvParser.extractExpiry(collectedTlvData) != null) {
440+
emvLog("GET_DATA 5F24: expiry=${TlvParser.extractExpiry(collectedTlvData)}")
441+
return
442+
}
443+
} catch (e: Exception) {
444+
emvLog("GET_DATA 5F24 CLA=${"%02X".format(cla)} exception=${e.message}")
445+
}
446+
}
447+
emvLog("GET_DATA 5F24: exhausted or unsupported")
448+
}
449+
410450
private fun tryGpoWithVariants(isoDep: IsoDep, pdol: ByteArray?): ByteArray {
411451
for ((index, ttq) in EmvConstants.TTQ_VARIANTS.withIndex()) {
412452
val pdolData = buildPdolData(pdol, ttq)
@@ -531,6 +571,7 @@ class EmvCardReader(
531571
logCollectedTlv("direct_after_scan")
532572
mergeCardholderFromGetData(isoDep)
533573
mergePinTryCounterFromGetData(isoDep)
574+
mergeExpiryFromGetData(isoDep)
534575
logCollectedTlv("direct_after_GET_DATA")
535576
foundPan?.let {
536577
emvLog("tryDirectRecordRead success last4=${it.takeLast(4)} name=${TlvParser.extractCardholderName(collectedTlvData)}")
@@ -555,7 +596,8 @@ class EmvCardReader(
555596
sourceDetectionResult = detectionResult,
556597
cardholderName = TlvParser.extractCardholderName(collectedTlvData),
557598
aid = TlvParser.extractAid(collectedTlvData),
558-
pinTriesRemaining = TlvParser.extractPinTryCounter(collectedTlvData)
599+
pinTriesRemaining = TlvParser.extractPinTryCounter(collectedTlvData),
600+
expiryDate = TlvParser.extractExpiry(collectedTlvData)
559601
)
560602
}
561603
}

android-bank-card-reader/src/main/java/com/emvreader/nfc/TlvParser.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ object TlvParser {
3636
const val TAG_DEVICE_TYPE = "9F6D"
3737
/** PIN Try Counter — offline PIN attempts remaining (often via GET DATA). */
3838
const val TAG_PIN_TRY_COUNTER = "9F17"
39+
/** Application Expiration Date — YYMMDD BCD (3 bytes). */
40+
const val TAG_EXPIRY = "5F24"
3941

4042
data class TlvData(val tag: String, val length: Int, val value: ByteArray)
4143

@@ -217,6 +219,23 @@ object TlvParser {
217219
return v[0].toInt() and 0xFF
218220
}
219221

222+
/**
223+
* Expiry from tag `5F24` (YYMMDD BCD, 3 bytes). Returns null if tag missing,
224+
* malformed, or month outside 1..12.
225+
*
226+
* Two-digit year mapped to 2000–2099 (EMV cards use 4-digit Gregorian year minus century).
227+
*/
228+
fun extractExpiry(tlvMap: Map<String, TlvData>): ExpiryDate? {
229+
val v = tlvMap[TAG_EXPIRY]?.value ?: return null
230+
if (v.size < 3) return null
231+
val digits = decodeBcd(v)
232+
if (digits.length < 4) return null
233+
val yy = digits.substring(0, 2).toIntOrNull() ?: return null
234+
val mm = digits.substring(2, 4).toIntOrNull() ?: return null
235+
if (mm !in 1..12) return null
236+
return ExpiryDate(year = 2000 + yy, month = mm)
237+
}
238+
220239
data class AflEntry(val sfi: Int, val firstRecord: Int, val lastRecord: Int)
221240

222241
private fun List<Byte>.toHex(): String = joinToString("") { "%02X".format(it) }
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.emvreader.nfc
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertFalse
5+
import org.junit.Assert.assertNull
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
import java.util.Calendar
9+
import java.util.GregorianCalendar
10+
11+
class ExpiryDateTest {
12+
13+
private fun tlv(hex: String): Map<String, TlvParser.TlvData> = mapOf(
14+
TlvParser.TAG_EXPIRY to TlvParser.TlvData(
15+
tag = TlvParser.TAG_EXPIRY,
16+
length = hex.length / 2,
17+
value = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
18+
)
19+
)
20+
21+
@Test
22+
fun extractExpiry_validBcd_returnsYearAndMonth() {
23+
val e = TlvParser.extractExpiry(tlv("271231"))
24+
assertEquals(ExpiryDate(2027, 12), e)
25+
}
26+
27+
@Test
28+
fun extractExpiry_missing_returnsNull() {
29+
assertNull(TlvParser.extractExpiry(emptyMap()))
30+
}
31+
32+
@Test
33+
fun extractExpiry_shortValue_returnsNull() {
34+
assertNull(TlvParser.extractExpiry(tlv("2712")))
35+
}
36+
37+
@Test
38+
fun extractExpiry_invalidMonth_returnsNull() {
39+
assertNull(TlvParser.extractExpiry(tlv("271331")))
40+
}
41+
42+
@Test
43+
fun displayMmYy_padsZero() {
44+
assertEquals("01/27", ExpiryDate(2027, 1).displayMmYy())
45+
assertEquals("12/05", ExpiryDate(2005, 12).displayMmYy())
46+
}
47+
48+
@Test
49+
fun displayMmYyyy_full() {
50+
assertEquals("03/2029", ExpiryDate(2029, 3).displayMmYyyy())
51+
}
52+
53+
@Test
54+
fun isExpired_pastMonth_true() {
55+
val now = GregorianCalendar(2026, Calendar.MAY, 16)
56+
assertTrue(ExpiryDate(2025, 12).isExpired(now))
57+
assertTrue(ExpiryDate(2026, 4).isExpired(now))
58+
}
59+
60+
@Test
61+
fun isExpired_currentMonth_false() {
62+
val now = GregorianCalendar(2026, Calendar.MAY, 16)
63+
assertFalse(ExpiryDate(2026, 5).isExpired(now))
64+
}
65+
66+
@Test
67+
fun isExpired_futureMonth_false() {
68+
val now = GregorianCalendar(2026, Calendar.MAY, 16)
69+
assertFalse(ExpiryDate(2026, 6).isExpired(now))
70+
assertFalse(ExpiryDate(2030, 1).isExpired(now))
71+
}
72+
73+
@Test
74+
fun success_expiryDate_storedOnSuccess() {
75+
val s = CardData.Success(
76+
pan = "4111111111111111",
77+
formattedPan = "4111 1111 1111 1111",
78+
maskedPan = "4111 **** **** 1111",
79+
cardType = CardType.VISA,
80+
expiryDate = ExpiryDate(2028, 6)
81+
)
82+
assertEquals(ExpiryDate(2028, 6), s.expiryDate)
83+
assertEquals("06/28", s.expiryDate?.displayMmYy())
84+
}
85+
}

0 commit comments

Comments
 (0)