Skip to content

Commit 1ce9924

Browse files
authored
Merge pull request #16 from disk0Dancer/copilot/fix-nfc-usage-issues
Implement AID extraction for NFC emulation and automate version from git tags
2 parents f913897 + 4d272b8 commit 1ce9924

File tree

16 files changed

+801
-28
lines changed

16 files changed

+801
-28
lines changed

app/build.gradle.kts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,34 @@ plugins {
77
id("com.google.dagger.hilt.android")
88
}
99

10+
// Function to extract version from git tag
11+
fun getVersionFromGit(): Pair<Int, String> {
12+
return try {
13+
val gitTag = Runtime.getRuntime()
14+
.exec("git describe --tags --abbrev=0")
15+
.inputStream.bufferedReader().readText().trim()
16+
17+
// Parse version tag (e.g., "v1.2.3" or "1.2.3")
18+
val versionMatch = Regex("""v?(\d+)\.(\d+)\.(\d+)""").find(gitTag)
19+
if (versionMatch != null) {
20+
val (major, minor, patch) = versionMatch.destructured
21+
// Calculate versionCode as major*10000 + minor*100 + patch
22+
val versionCode = major.toInt() * 10000 + minor.toInt() * 100 + patch.toInt()
23+
val versionName = "$major.$minor.$patch"
24+
Pair(versionCode, versionName)
25+
} else {
26+
// Fallback if no valid tag found
27+
Pair(1, "0.1.0")
28+
}
29+
} catch (e: Exception) {
30+
// Fallback to default version if git command fails
31+
println("Warning: Could not read git tag, using default version. Error: ${e.message}")
32+
Pair(1, "0.1.0")
33+
}
34+
}
35+
36+
val (appVersionCode, appVersionName) = getVersionFromGit()
37+
1038
android {
1139
namespace = "com.nfcbumber"
1240
compileSdk = 35
@@ -15,8 +43,8 @@ android {
1543
applicationId = "com.nfcbumber"
1644
minSdk = 26
1745
targetSdk = 35
18-
versionCode = 4
19-
versionName = "2.0.0"
46+
versionCode = appVersionCode
47+
versionName = appVersionName
2048

2149
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2250
vectorDrawables {

app/src/main/res/xml/apduservice.xml

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,32 @@
44
android:requireDeviceUnlock="false"
55
android:apduServiceBanner="@mipmap/ic_launcher">
66

7-
<!-- Default AID for generic card emulation -->
7+
<!-- AID group for access control, transit, and identification cards -->
88
<aid-group android:description="@string/aid_group_description" android:category="other">
9-
<!-- Generic application identifier for NFC card emulation -->
9+
<!-- Generic/Default AIDs -->
1010
<aid-filter android:name="F0010203040506"/>
11-
<!-- Alternative AID for compatibility -->
11+
<aid-filter android:name="F04E4643424D42455200"/>
12+
13+
<!-- Payment AIDs (for reference, most terminals won't accept them from "other" category) -->
1214
<aid-filter android:name="A0000000031010"/>
15+
<aid-filter android:name="A0000000041010"/>
16+
<aid-filter android:name="A0000000032010"/>
17+
<aid-filter android:name="A0000000999999"/>
18+
19+
<!-- MIFARE DESFire AIDs (common in access control) -->
20+
<aid-filter android:name="D2760000850100"/>
21+
<aid-filter android:name="D2760000850101"/>
22+
23+
<!-- Transit card AIDs -->
24+
<aid-filter android:name="315449432E494341"/>
25+
26+
<!-- Access control AIDs -->
27+
<aid-filter android:name="A000000618"/>
28+
<aid-filter android:name="A00000061701"/>
29+
30+
<!-- Common transport and access control AIDs -->
31+
<aid-filter android:name="F0000000000000"/>
32+
<aid-filter android:name="A00000015100"/>
33+
<aid-filter android:name="D2760000850100"/>
1334
</aid-group>
1435
</host-apdu-service>

data/src/main/kotlin/com/nfcbumber/data/database/AppDatabase.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import androidx.room.TypeConverters
99
*/
1010
@Database(
1111
entities = [CardEntity::class],
12-
version = 1,
12+
version = 2,
1313
exportSchema = true
1414
)
1515
@TypeConverters(Converters::class)

data/src/main/kotlin/com/nfcbumber/data/database/CardEntity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ data class CardEntity(
1414
val uid: ByteArray,
1515
val ats: ByteArray?,
1616
val historicalBytes: ByteArray?,
17+
val aids: String = "", // Comma-separated Application Identifiers
1718
val cardType: String,
1819
val color: Int,
1920
val createdAt: Long,

data/src/main/kotlin/com/nfcbumber/data/di/DatabaseModule.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package com.nfcbumber.data.di
22

33
import android.content.Context
44
import androidx.room.Room
5+
import androidx.room.migration.Migration
6+
import androidx.sqlite.db.SupportSQLiteDatabase
57
import com.nfcbumber.data.database.AppDatabase
68
import com.nfcbumber.data.database.CardDao
79
import dagger.Module
@@ -18,6 +20,13 @@ import javax.inject.Singleton
1820
@InstallIn(SingletonComponent::class)
1921
object DatabaseModule {
2022

23+
private val MIGRATION_1_2 = object : Migration(1, 2) {
24+
override fun migrate(database: SupportSQLiteDatabase) {
25+
// Add the aids column to the cards table
26+
database.execSQL("ALTER TABLE cards ADD COLUMN aids TEXT NOT NULL DEFAULT ''")
27+
}
28+
}
29+
2130
@Provides
2231
@Singleton
2332
fun provideAppDatabase(
@@ -28,6 +37,7 @@ object DatabaseModule {
2837
AppDatabase::class.java,
2938
AppDatabase.DATABASE_NAME
3039
)
40+
.addMigrations(MIGRATION_1_2)
3141
.fallbackToDestructiveMigration()
3242
.build()
3343
}

data/src/main/kotlin/com/nfcbumber/data/mapper/CardMapper.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ object CardMapper {
1818
uid = entity.uid,
1919
ats = entity.ats,
2020
historicalBytes = entity.historicalBytes,
21+
aids = if (entity.aids.isNotEmpty()) entity.aids.split(",") else emptyList(),
2122
cardType = CardType.valueOf(entity.cardType),
2223
color = entity.color,
2324
createdAt = LocalDateTime.ofInstant(
@@ -41,6 +42,7 @@ object CardMapper {
4142
uid = card.uid,
4243
ats = card.ats,
4344
historicalBytes = card.historicalBytes,
45+
aids = card.aids.joinToString(","),
4446
cardType = card.cardType.name,
4547
color = card.color,
4648
createdAt = card.createdAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),

data/src/main/kotlin/com/nfcbumber/data/nfc/NfcEmulatorService.kt

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -114,27 +114,46 @@ class NfcEmulatorService : HostApduService() {
114114

115115
/**
116116
* Handle SELECT command (00 A4 04 00).
117+
* Verifies that the requested AID matches one of the card's AIDs.
117118
* Returns ATS (Answer To Select) if available.
118119
*/
119120
private fun handleSelectCommand(commandApdu: ByteArray): ByteArray {
120121
Log.d(TAG, "Processing SELECT command")
121122

122123
val card = getSelectedCard()
123124

124-
return if (card != null) {
125-
Log.d(TAG, "Card selected: ${card.name}, UID: ${card.uid.toHexString()}")
126-
127-
// Return ATS if available, otherwise just success
128-
if (card.ats != null && card.ats.isNotEmpty()) {
129-
Log.d(TAG, "Returning ATS: ${card.ats.toHexString()}")
130-
card.ats + SW_SUCCESS
131-
} else {
132-
Log.d(TAG, "No ATS available, returning success")
133-
SW_SUCCESS
125+
if (card == null) {
126+
Log.w(TAG, "No card selected for emulation")
127+
return SW_FILE_NOT_FOUND
128+
}
129+
130+
// Extract the requested AID from the SELECT command
131+
// Format: 00 A4 04 00 Lc [AID bytes]
132+
if (commandApdu.size >= 6) {
133+
val aidLength = commandApdu[4].toInt() and 0xFF
134+
if (commandApdu.size >= 5 + aidLength) {
135+
val requestedAid = commandApdu.copyOfRange(5, 5 + aidLength).toHexString()
136+
Log.d(TAG, "Terminal requested AID: $requestedAid")
137+
138+
// Check if the card supports this AID
139+
if (card.aids.isNotEmpty() && !card.aids.contains(requestedAid)) {
140+
Log.w(TAG, "Card does not support requested AID: $requestedAid")
141+
Log.d(TAG, "Card supports AIDs: ${card.aids.joinToString()}")
142+
// Return file not found for unsupported AID
143+
return SW_FILE_NOT_FOUND
144+
}
145+
146+
Log.d(TAG, "AID matched, card selected: ${card.name}")
134147
}
148+
}
149+
150+
// Return ATS if available, otherwise just success
151+
return if (card.ats != null && card.ats.isNotEmpty()) {
152+
Log.d(TAG, "Returning ATS: ${card.ats.toHexString()}")
153+
card.ats + SW_SUCCESS
135154
} else {
136-
Log.w(TAG, "No card selected for emulation")
137-
SW_FILE_NOT_FOUND
155+
Log.d(TAG, "No ATS available, returning success")
156+
SW_SUCCESS
138157
}
139158
}
140159

@@ -200,10 +219,17 @@ class NfcEmulatorService : HostApduService() {
200219
runBlocking {
201220
val cardEntity = cardDao.getCardById(selectedCardId)
202221
if (cardEntity != null) {
222+
val aids = if (cardEntity.aids.isNotEmpty()) {
223+
cardEntity.aids.split(",")
224+
} else {
225+
emptyList()
226+
}
227+
203228
EmulatedCardData(
204229
uid = cardEntity.uid,
205230
ats = cardEntity.ats,
206231
historicalBytes = cardEntity.historicalBytes,
232+
aids = aids,
207233
name = cardEntity.name
208234
)
209235
} else {
@@ -246,6 +272,7 @@ private data class EmulatedCardData(
246272
val uid: ByteArray,
247273
val ats: ByteArray?,
248274
val historicalBytes: ByteArray?,
275+
val aids: List<String>,
249276
val name: String
250277
) {
251278
override fun equals(other: Any?): Boolean {

data/src/main/kotlin/com/nfcbumber/data/nfc/NfcReaderService.kt

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,25 +43,29 @@ class NfcReaderService @Inject constructor() {
4343
Log.d(TAG, "Available technologies: ${techList.joinToString()}")
4444

4545
// Try to read as ISO-DEP first (most common for smart cards)
46-
val (ats, historicalBytes, cardType) = when {
46+
val (cardData, aids) = when {
4747
techList.contains(IsoDep::class.java.name) -> readIsoDep(tag)
48-
techList.contains(MifareClassic::class.java.name) -> readMifareClassic(tag)
49-
techList.contains(MifareUltralight::class.java.name) -> readMifareUltralight(tag)
50-
techList.contains(NfcA::class.java.name) -> readNfcA(tag)
51-
techList.contains(NfcB::class.java.name) -> readNfcB(tag)
52-
techList.contains(NfcF::class.java.name) -> readNfcF(tag)
53-
techList.contains(NfcV::class.java.name) -> readNfcV(tag)
54-
else -> Triple(null, null, CardType.UNKNOWN)
48+
techList.contains(MifareClassic::class.java.name) -> Pair(readMifareClassic(tag), emptyList())
49+
techList.contains(MifareUltralight::class.java.name) -> Pair(readMifareUltralight(tag), emptyList())
50+
techList.contains(NfcA::class.java.name) -> Pair(readNfcA(tag), emptyList())
51+
techList.contains(NfcB::class.java.name) -> Pair(readNfcB(tag), emptyList())
52+
techList.contains(NfcF::class.java.name) -> Pair(readNfcF(tag), emptyList())
53+
techList.contains(NfcV::class.java.name) -> Pair(readNfcV(tag), emptyList())
54+
else -> Pair(Triple(null, null, CardType.UNKNOWN), emptyList())
5555
}
5656

57+
val (ats, historicalBytes, cardType) = cardData
58+
5759
Log.d(TAG, "Card type: $cardType")
5860
Log.d(TAG, "ATS: ${ats?.toHexString() ?: "N/A"}")
5961
Log.d(TAG, "Historical bytes: ${historicalBytes?.toHexString() ?: "N/A"}")
62+
Log.d(TAG, "Discovered AIDs: ${aids.joinToString()}")
6063

6164
NfcCardData(
6265
uid = uid,
6366
ats = ats,
6467
historicalBytes = historicalBytes,
68+
aids = aids,
6569
cardType = cardType
6670
)
6771
} catch (e: Exception) {
@@ -70,7 +74,7 @@ class NfcReaderService @Inject constructor() {
7074
}
7175
}
7276

73-
private fun readIsoDep(tag: Tag): Triple<ByteArray?, ByteArray?, CardType> {
77+
private fun readIsoDep(tag: Tag): Pair<Triple<ByteArray?, ByteArray?, CardType>, List<String>> {
7478
val isoDep = IsoDep.get(tag)
7579
return try {
7680
isoDep.connect()
@@ -79,7 +83,10 @@ class NfcReaderService @Inject constructor() {
7983
val ats = isoDep.historicalBytes ?: isoDep.hiLayerResponse
8084
val historicalBytes = isoDep.historicalBytes
8185

82-
Triple(ats, historicalBytes, CardType.ISO_DEP)
86+
// Try to discover AIDs by sending SELECT commands for common AIDs
87+
val aids = discoverAids(isoDep)
88+
89+
Pair(Triple(ats, historicalBytes, CardType.ISO_DEP), aids)
8390
} finally {
8491
try {
8592
if (isoDep.isConnected) {
@@ -90,6 +97,63 @@ class NfcReaderService @Inject constructor() {
9097
}
9198
}
9299
}
100+
101+
/**
102+
* Discover AIDs supported by the card by probing common AIDs.
103+
* This helps identify what applications the card supports.
104+
*/
105+
private fun discoverAids(isoDep: IsoDep): List<String> {
106+
val discoveredAids = mutableListOf<String>()
107+
108+
// Common AIDs for access control, transit, and payment systems
109+
val commonAids = listOf(
110+
"F0010203040506", // Generic/Default AID
111+
"A0000000031010", // Visa
112+
"A0000000041010", // Mastercard
113+
"A0000000032010", // Visa Electron
114+
"A0000000999999", // Generic payment
115+
"D2760000850100", // MIFARE DESFire
116+
"D2760000850101", // MIFARE DESFire EV1
117+
"315449432E494341", // Transit card (STIC.ICA)
118+
"A000000618", // Access control
119+
"A00000061701", // Access control (HID)
120+
"F04E4643424D42455200" // NFCBUMBER (our app AID)
121+
)
122+
123+
for (aid in commonAids) {
124+
try {
125+
val selectCommand = buildSelectApdu(aid)
126+
val response = isoDep.transceive(selectCommand)
127+
128+
// Check if response indicates success (SW1 SW2 = 90 00)
129+
if (response.size >= 2 &&
130+
response[response.size - 2] == 0x90.toByte() &&
131+
response[response.size - 1] == 0x00.toByte()) {
132+
Log.d(TAG, "Discovered AID: $aid")
133+
discoveredAids.add(aid)
134+
}
135+
} catch (e: Exception) {
136+
// Card doesn't support this AID, continue
137+
Log.v(TAG, "AID $aid not supported: ${e.message}")
138+
}
139+
}
140+
141+
return discoveredAids
142+
}
143+
144+
/**
145+
* Build a SELECT APDU command for a given AID.
146+
*/
147+
private fun buildSelectApdu(aid: String): ByteArray {
148+
val aidBytes = aid.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
149+
return byteArrayOf(
150+
0x00.toByte(), // CLA
151+
0xA4.toByte(), // INS (SELECT)
152+
0x04.toByte(), // P1 (Select by name)
153+
0x00.toByte(), // P2
154+
aidBytes.size.toByte() // Lc (length of AID)
155+
) + aidBytes
156+
}
93157

94158
private fun readMifareClassic(tag: Tag): Triple<ByteArray?, ByteArray?, CardType> {
95159
val mifare = MifareClassic.get(tag)
@@ -233,6 +297,7 @@ data class NfcCardData(
233297
val uid: ByteArray,
234298
val ats: ByteArray?,
235299
val historicalBytes: ByteArray?,
300+
val aids: List<String> = emptyList(),
236301
val cardType: CardType
237302
) {
238303
override fun equals(other: Any?): Boolean {
@@ -250,6 +315,7 @@ data class NfcCardData(
250315
if (other.historicalBytes == null) return false
251316
if (!historicalBytes.contentEquals(other.historicalBytes)) return false
252317
} else if (other.historicalBytes != null) return false
318+
if (aids != other.aids) return false
253319
if (cardType != other.cardType) return false
254320

255321
return true
@@ -259,6 +325,7 @@ data class NfcCardData(
259325
var result = uid.contentHashCode()
260326
result = 31 * result + (ats?.contentHashCode() ?: 0)
261327
result = 31 * result + (historicalBytes?.contentHashCode() ?: 0)
328+
result = 31 * result + aids.hashCode()
262329
result = 31 * result + cardType.hashCode()
263330
return result
264331
}

0 commit comments

Comments
 (0)