init - poc
Auto Tag on Version Change / check-version (push) Successful in 3s

This commit is contained in:
2026-10-01 23:23:43 +05:00
parent 810332f19e
commit 4fdb8272bc
39 changed files with 1944 additions and 216 deletions
+3
View File
@@ -0,0 +1,3 @@
KEYSTORE_PASSWORD=your_keystore_password_here
KEY_ALIAS=your_key_alias_here
KEY_PASSWORD=your_key_password_here
+2
View File
@@ -0,0 +1,2 @@
.env
release/
+9
View File
@@ -0,0 +1,9 @@
services:
release:
image: git.shihaam.dev/dockerfiles/runners/gradle
network_mode: host
env_file: .env
volumes:
- ./release:/release
- ../../:/source
- /root/.cache/cache-runners/gradle:/root/.gradle
+50
View File
@@ -0,0 +1,50 @@
name: Auto Tag on Version Change
on:
push:
branches:
- main
jobs:
check-version:
runs-on: docker-compose
steps:
- name: Checkout source code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PAT_GITEA }}
- name: Check version and prepare release
id: version
run: |
VERSION=$(grep 'versionName = ' app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
BEFORE="${{ github.event.before }}"
if [ -z "$BEFORE" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
BEFORE="HEAD~1"
fi
PREV_VERSION_CODE=$(git show "${BEFORE}:app/build.gradle.kts" 2>/dev/null | grep 'versionCode = ' | sed 's/.*versionCode = \([0-9]*\).*/\1/')
if [ "$VERSION_CODE" = "$PREV_VERSION_CODE" ]; then
echo "versionCode unchanged (${VERSION_CODE}), skipping"
echo "should_release=false" >> $GITHUB_OUTPUT
elif git tag -l | grep -q "^v${VERSION}$"; then
echo "versionCode changed (${PREV_VERSION_CODE} -> ${VERSION_CODE}) but tag v${VERSION} already exists; bump versionName"
exit 1
else
echo "New versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})"
echo "should_release=true" >> $GITHUB_OUTPUT
fi
- name: Create and push tag
if: steps.version.outputs.should_release == 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
git tag "v${VERSION}"
git push origin "v${VERSION}"
echo "Created and pushed tag v${VERSION}"
+86
View File
@@ -0,0 +1,86 @@
name: Build and Release APK
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: docker-compose
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Setup keystore and environment
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n' | base64 -d > app/key.jks
echo "${{ secrets.DOTENV_BASE64 }}" | tr -d '\n' | base64 -d > .build/release/.env
- name: Build APK
working-directory: .build/release
run: docker compose run --rm release
- name: Rename APK
run: |
APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME//\//-}"
TAG="${{ gitea.ref_name }}"
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
- name: Extract release notes
run: |
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
if [ -f "$CHANGELOG" ]; then
cp "$CHANGELOG" release_notes.md
else
echo "No changelog found at $CHANGELOG"
echo "No release notes" > release_notes.md
fi
- name: Create Gitea Release
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
run: |
APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME//\//-}"
TAG="${{ gitea.ref_name }}"
TITLE="${APP_NAME} ${TAG}"
NOTES_FILE="release_notes.md"
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
API_URL="${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases"
RELEASE_RESPONSE=$(curl -s -X POST "$API_URL" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG}\",
\"name\": \"${TITLE}\",
\"body\": $(jq -Rs . < "$NOTES_FILE")
}")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
echo "Failed to create release:"
echo "$RELEASE_RESPONSE"
exit 1
fi
echo "Created release with ID: $RELEASE_ID"
ASSET_NAME=$(basename "$ASSET_PATH")
UPLOAD_URL="${API_URL}/${RELEASE_ID}/assets?name=${ASSET_NAME}"
curl -s -X POST "$UPLOAD_URL" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${ASSET_PATH}"
echo "Uploaded asset: $ASSET_NAME"
+45 -1
View File
@@ -1,3 +1,47 @@
# sw9000-android
Android app that reads NFC EMV cards and dumps everything: PPSE, AIDs, TLV tree, track2, PAN, expiry, AFL records, with raw APDUs.
A read-only Android app that reads a contactless EMV card over NFC and shows **everything**
obtainable from it, with full APDU-level transparency. For reading your own cards.
## What it does
On launch it checks NFC:
- **No NFC hardware** → an "NFC not supported" screen.
- **NFC off** → a prompt with a button to open NFC settings.
- **NFC on** → the "tap a card" animation (a card swinging onto a phone with NFC ripples,
ported from the sibling `android` app). On tap it reads the card and shows:
- **Formatted** — PAN, cardholder name, expiry, valid-from, PAN sequence, application label /
preferred name / AID, issuer country, currency, ATC, PIN-try counter, AIP, Track 2, plus the
tag's UID / technologies / ATQA / SAK / historical bytes.
- **Raw dump** — the full BER-TLV tree (every tag labelled and interpreted) for each
application, and the complete **APDU log**: every command sent, every response, and the
decoded status word.
The read flow is: SELECT PPSE → for each advertised AID: SELECT AID → GET PROCESSING OPTIONS
(PDOL filled in) → READ RECORD across the AFL → GET DATA for the common counters. It never
writes to the card and runs no transaction (no GENERATE AC), so tapping is harmless.
## Scheme / country / bank
Derived with no network:
- **Scheme** (Visa / Mastercard / Amex / UnionPay / JCB / Discover …) — from the application AID's
RID and, as a fallback, the PAN's leading digits. Reliable.
- **Issuer country** — from EMV tag `5F28` (ISO-3166 numeric), decoded to a country name. Present
on most cards.
- **Issuer bank / card type / product** — *not* stored on the card. Naming the bank needs a
**BIN/IIN database**, which you can optionally bundle at build time: drop a tab-separated dataset
at `app/src/main/assets/bins.tsv` (format documented in that file). It compiles into the APK, so
the app still makes no network calls. Without it, the app shows the raw BIN for manual lookup,
and the application label (`50` / `9F12`) often carries the issuer's branding anyway.
## Privacy
No network. The app declares only `android.permission.NFC` — **no `INTERNET` permission**, so
nothing read from a card can leave the device.
## Build
Standard Gradle / Android Studio project (AGP 9, Kotlin 2.2, Jetpack Compose). Needs a physical
NFC-capable device; the emulator can't read cards.
+3 -1
View File
@@ -1 +1,3 @@
/build
/build
# Signing keystore written by CI — never commit it.
key.jks
+14 -3
View File
@@ -6,9 +6,7 @@ plugins {
android {
namespace = "sh.sar.sw9000"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
version = release(37)
}
defaultConfig {
@@ -21,8 +19,21 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
// Supplied by CI: key.jks is written next to this module and the passwords come
// from the environment (.build/release/.env / Gitea secrets). Left unset locally,
// which is fine unless you run assembleRelease.
storeFile = file("key.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
+5
View File
@@ -2,6 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Read-only contactless card reader. No INTERNET permission: nothing leaves the device. -->
<uses-permission android:name="android.permission.NFC" />
<!-- Not required, so the app still installs on NFC-less devices and shows the unsupported screen. -->
<uses-feature android:name="android.hardware.nfc" android:required="false" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
+22
View File
@@ -0,0 +1,22 @@
# sw9000 offline BIN / IIN database (optional).
#
# Drop a dataset here to let the app name the issuing BANK, card TYPE and PRODUCT offline.
# This file is compiled into the APK at build time — the app makes NO network calls and keeps
# the INTERNET permission off. If this file has no data rows, the app just shows the raw BIN.
#
# Format: tab-separated, one range per line, '#' lines ignored. Columns:
#
# iin_start <TAB> iin_end <TAB> scheme <TAB> type <TAB> brand <TAB> bank <TAB> country
#
# iin_start / iin_end : inclusive range over the first up-to-8 PAN digits. Pad a shorter prefix
# with 0s on start and 9s on end — e.g. the 6-digit prefix 457173 -> 45717300 and 45717399.
# Any trailing column may be left blank. Ranges should not overlap; the narrowest match wins.
#
# Example rows (SYNTHETIC — replace with a real dataset):
# 45717300 45717399 Visa credit Classic Example Bank United Kingdom
# 55555500 55555599 Mastercard debit Example Bank United States
#
# Where to get data (check each source's licence before shipping):
# - Commercial BIN providers (most accurate, licensed).
# - Open community lists on GitHub (quality/licensing varies).
# Convert whatever you obtain into the tab-separated format above.
1 # sw9000 offline BIN / IIN database (optional).
2 #
3 # Drop a dataset here to let the app name the issuing BANK, card TYPE and PRODUCT offline.
4 # This file is compiled into the APK at build time — the app makes NO network calls and keeps
5 # the INTERNET permission off. If this file has no data rows, the app just shows the raw BIN.
6 #
7 # Format: tab-separated, one range per line, '#' lines ignored. Columns:
8 #
9 # iin_start <TAB> iin_end <TAB> scheme <TAB> type <TAB> brand <TAB> bank <TAB> country
10 #
11 # iin_start / iin_end : inclusive range over the first up-to-8 PAN digits. Pad a shorter prefix
12 # with 0s on start and 9s on end — e.g. the 6-digit prefix 457173 -> 45717300 and 45717399.
13 # Any trailing column may be left blank. Ranges should not overlap; the narrowest match wins.
14 #
15 # Example rows (SYNTHETIC — replace with a real dataset):
16 # 45717300 45717399 Visa credit Classic Example Bank United Kingdom
17 # 55555500 55555599 Mastercard debit Example Bank United States
18 #
19 # Where to get data (check each source's licence before shipping):
20 # - Commercial BIN providers (most accurate, licensed).
21 # - Open community lists on GitHub (quality/licensing varies).
22 # Convert whatever you obtain into the tab-separated format above.
+104 -22
View File
@@ -1,47 +1,129 @@
package sh.sar.sw9000
import android.content.Intent
import android.nfc.NfcAdapter
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import kotlin.concurrent.thread
import sh.sar.sw9000.nfc.BinLookup
import sh.sar.sw9000.nfc.EmvCardReader
import sh.sar.sw9000.ui.NfcDisabledScreen
import sh.sar.sw9000.ui.ResultScreen
import sh.sar.sw9000.ui.theme.Sw9000Theme
import sh.sar.sw9000.ui.TapState
import sh.sar.sw9000.ui.UnsupportedScreen
import sh.sar.sw9000.ui.WaitingScreen
/** Reader screen states, driven by the NFC adapter and the active read. */
sealed interface UiState {
data object Unsupported : UiState
data object Disabled : UiState
data class Waiting(val tap: TapState) : UiState
data class Done(val result: EmvCardReader.Result) : UiState
}
/**
* Single-activity, read-only NFC/EMV dumper. The activity owns the NFC adapter: it enables
* reader mode whenever we're on the waiting screen and tears it down otherwise. The reader-mode
* callback runs on a binder thread, so the (blocking) card read happens there and the result is
* posted back to Compose state on the main thread.
*/
class MainActivity : ComponentActivity() {
private var adapter: NfcAdapter? = null
private var uiState by mutableStateOf<UiState>(UiState.Waiting(TapState.WAITING))
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = NfcAdapter.getDefaultAdapter(this)
// Load the optional bundled BIN database off the main thread (no-op if none is bundled).
thread { BinLookup.init(applicationContext) }
enableEdgeToEdge()
setContent {
Sw9000Theme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { TopAppBar(title = { Text(getString(R.string.app_name)) }) },
) { inner ->
when (val s = uiState) {
UiState.Unsupported -> UnsupportedScreen(Modifier.padding(inner))
UiState.Disabled -> NfcDisabledScreen(
onOpenSettings = { startActivity(Intent(Settings.ACTION_NFC_SETTINGS)) },
modifier = Modifier.padding(inner),
)
is UiState.Waiting -> WaitingScreen(
state = s.tap,
label = if (s.tap == TapState.READING) getString(R.string.reading)
else getString(R.string.tap_card),
modifier = Modifier.padding(inner),
)
is UiState.Done -> ResultScreen(
result = s.result,
onReadAnother = { uiState = UiState.Waiting(TapState.WAITING) },
modifier = Modifier.padding(inner),
)
}
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
Sw9000Theme {
Greeting("Android")
override fun onResume() {
super.onResume()
syncAdapterState()
}
}
override fun onPause() {
super.onPause()
runCatching { adapter?.disableReaderMode(this) }
}
/** Reconciles the current UI state with NFC availability and (re)arms reader mode. */
private fun syncAdapterState() {
val a = adapter
when {
a == null -> uiState = UiState.Unsupported
!a.isEnabled -> uiState = UiState.Disabled
else -> {
// Coming back from Disabled/Unsupported resets us to waiting.
if (uiState is UiState.Unsupported || uiState is UiState.Disabled) {
uiState = UiState.Waiting(TapState.WAITING)
}
enableReader(a)
}
}
}
private fun enableReader(a: NfcAdapter) {
val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
a.enableReaderMode(this, { tag ->
// Binder thread — safe to block on card I/O here.
runOnUiThread { if (uiState is UiState.Waiting) uiState = UiState.Waiting(TapState.READING) }
thread {
val result = runCatching { EmvCardReader.read(tag) }.getOrElse {
EmvCardReader.Result(
uid = "", techList = emptyList(), historicalBytes = null, hiLayerResponse = null,
atqa = null, sak = null, apps = emptyList(), fields = emptyList(), apduLog = emptyList(),
error = "Read error: ${it.message ?: it.javaClass.simpleName}",
)
}
runOnUiThread { uiState = UiState.Done(result) }
}
}, flags, null)
}
}
@@ -0,0 +1,107 @@
package sh.sar.sw9000.nfc
import android.content.Context
import java.io.BufferedReader
/**
* Optional, fully-offline issuer lookup from the card's BIN/IIN (leading PAN digits).
*
* The card itself does NOT name the issuing bank; that needs an external BIN database. This loads
* one — if you bundle it — from `assets/bins.tsv`, built into the APK at compile time, so the app
* still makes no network calls and keeps the INTERNET permission off. If the asset is missing or
* empty, [lookup] simply returns null and the UI falls back to showing the raw BIN.
*
* ## File format (`app/src/main/assets/bins.tsv`), tab-separated, `#` lines ignored:
* ```
* iin_start iin_end scheme type brand bank country
* ```
* `iin_start`/`iin_end` are the inclusive range of the first up-to-8 PAN digits (pad shorter
* prefixes with 0s on the start and 9s on the end — e.g. the 6-digit prefix `457173` becomes
* `45717300`–`45717399`). Any trailing column may be left blank. Ranges should not overlap; if
* they do, the narrowest match wins.
*/
object BinLookup {
data class Issuer(
val scheme: String?,
val type: String?, // debit / credit / prepaid, if known
val brand: String?, // product name, e.g. "Platinum"
val bank: String?,
val country: String?,
) {
val isEmpty get() = listOf(scheme, type, brand, bank, country).all { it.isNullOrBlank() }
}
@Volatile private var loaded = false
private var starts = LongArray(0)
private var ends = LongArray(0)
private var info = emptyArray<Issuer>()
/** Loads the bundled dataset once. Safe to call repeatedly; no-op after the first success. */
@Synchronized
fun init(context: Context) {
if (loaded) return
loaded = true
runCatching {
context.assets.open("bins.tsv").bufferedReader().use { parse(it) }
}.onFailure {
// No asset bundled (or unreadable): leave the index empty — lookups just return null.
starts = LongArray(0); ends = LongArray(0); info = emptyArray()
}
}
/** Returns issuer details for [pan], or null if no dataset is bundled or nothing matches. */
fun lookup(pan: String?): Issuer? {
if (starts.isEmpty() || pan == null) return null
val digits = pan.filter { it.isDigit() }
if (digits.length < 6) return null
val key = digits.take(8).padEnd(8, '0').toLongOrNull() ?: return null
// Rightmost range whose start <= key, then widen the search backward for a container,
// keeping the narrowest (most specific) match.
var lo = 0; var hi = starts.size - 1; var idx = -1
while (lo <= hi) {
val mid = (lo + hi) ushr 1
if (starts[mid] <= key) { idx = mid; lo = mid + 1 } else hi = mid - 1
}
if (idx < 0) return null
var best = -1
var i = idx
var scanned = 0
while (i >= 0 && scanned < 64) {
if (ends[i] >= key) {
if (best < 0 || (ends[i] - starts[i]) < (ends[best] - starts[best])) best = i
}
i--; scanned++
}
return if (best >= 0) info[best] else null
}
private fun parse(reader: BufferedReader) {
val s = ArrayList<Long>(1 shl 16)
val e = ArrayList<Long>(1 shl 16)
val list = ArrayList<Issuer>(1 shl 16)
reader.forEachLine { raw ->
val line = raw.trim()
if (line.isEmpty() || line.startsWith("#")) return@forEachLine
val c = line.split('\t')
val start = c.getOrNull(0)?.trim()?.padEnd(8, '0')?.toLongOrNull() ?: return@forEachLine
val end = c.getOrNull(1)?.trim()?.padEnd(8, '9')?.toLongOrNull() ?: start
s.add(start); e.add(end)
list.add(
Issuer(
scheme = c.getOrNull(2)?.trim()?.ifBlank { null },
type = c.getOrNull(3)?.trim()?.ifBlank { null },
brand = c.getOrNull(4)?.trim()?.ifBlank { null },
bank = c.getOrNull(5)?.trim()?.ifBlank { null },
country = c.getOrNull(6)?.trim()?.ifBlank { null },
)
)
}
// Sort all three arrays together by range start.
val order = (0 until s.size).sortedBy { s[it] }
starts = LongArray(order.size) { s[order[it]] }
ends = LongArray(order.size) { e[order[it]] }
info = Array(order.size) { list[order[it]] }
}
}
@@ -0,0 +1,139 @@
package sh.sar.sw9000.nfc
/**
* Derives what a card reveals about itself without any network lookup: the payment **scheme**
* (Visa / Mastercard / Amex …) from the application AID and the PAN, and the **issuer country**
* from EMV tag 5F28.
*
* The specific issuing **bank** is deliberately not guessed: that needs a BIN/IIN database, which
* isn't on the card. The BIN (leading PAN digits) is exposed instead so it can be looked up
* elsewhere, and the application label often carries the issuer's own branding.
*/
object CardIdentity {
/** Scheme name from the AID's RID (first 5 bytes / 10 hex chars), or null if unrecognised. */
fun schemeFromAid(aidHex: String): String? {
val rid = aidHex.uppercase().take(10)
return RID[rid]
}
/** Scheme name inferred from the PAN's leading digits (IIN), or null. */
fun schemeFromPan(pan: String): String? {
val d = pan.filter { it.isDigit() }
if (d.length < 2) return null
fun p(n: Int) = d.take(n).toIntOrNull() ?: -1
return when {
d[0] == '4' -> "Visa"
p(2) in 34..34 || p(2) == 37 -> "American Express"
p(2) in 51..55 || p(4) in 2221..2720 -> "Mastercard"
p(4) == 6011 || p(2) == 65 || p(3) in 644..649 -> "Discover"
p(2) == 35 -> "JCB"
p(2) == 62 || p(2) == 81 -> "UnionPay"
p(2) == 36 || p(2) == 38 || p(3) in 300..305 -> "Diners Club"
p(2) == 50 || p(2) in 56..69 -> "Maestro"
p(4) in 6521..6522 -> "RuPay"
else -> null
}
}
/** Best-effort scheme: AID first (authoritative), PAN as fallback. */
fun scheme(aidHex: String?, pan: String?): String? =
aidHex?.let { schemeFromAid(it) } ?: pan?.let { schemeFromPan(it) }
/** Country name for an ISO-3166-1 numeric code (as a 3-digit string), or the code itself. */
fun country(numeric: String): String {
val code = numeric.filter { it.isDigit() }.trimStart('0').padStart(3, '0')
return COUNTRIES[code]?.let { "$it ($code)" } ?: numeric
}
// Registered Application Provider Identifiers (RID) → scheme.
private val RID = mapOf(
"A000000003" to "Visa",
"A000000004" to "Mastercard",
"A000000005" to "Mastercard",
"A000000025" to "American Express",
"A000000065" to "JCB",
"A000000152" to "Discover",
"A000000324" to "Discover",
"A000000333" to "UnionPay",
"A000000277" to "Interac",
"A000000042" to "Cartes Bancaires",
"A000000524" to "RuPay",
"A000000658" to "Mir",
"A000000371" to "Verve",
"A000000228" to "SPAN (Saudi)",
"A000000098" to "Visa (Debit)",
"A000000620" to "Interac",
)
// ISO 3166-1 numeric → country / territory name. Full assigned list.
private val COUNTRIES = mapOf(
"004" to "Afghanistan", "008" to "Albania", "010" to "Antarctica", "012" to "Algeria",
"016" to "American Samoa", "020" to "Andorra", "024" to "Angola", "028" to "Antigua and Barbuda",
"031" to "Azerbaijan", "032" to "Argentina", "036" to "Australia", "040" to "Austria",
"044" to "Bahamas", "048" to "Bahrain", "050" to "Bangladesh", "051" to "Armenia",
"052" to "Barbados", "056" to "Belgium", "060" to "Bermuda", "064" to "Bhutan",
"068" to "Bolivia", "070" to "Bosnia and Herzegovina", "072" to "Botswana", "074" to "Bouvet Island",
"076" to "Brazil", "084" to "Belize", "086" to "British Indian Ocean Territory",
"090" to "Solomon Islands", "092" to "British Virgin Islands", "096" to "Brunei",
"100" to "Bulgaria", "104" to "Myanmar", "108" to "Burundi", "112" to "Belarus",
"116" to "Cambodia", "120" to "Cameroon", "124" to "Canada", "132" to "Cape Verde",
"136" to "Cayman Islands", "140" to "Central African Republic", "144" to "Sri Lanka",
"148" to "Chad", "152" to "Chile", "156" to "China", "158" to "Taiwan",
"162" to "Christmas Island", "166" to "Cocos (Keeling) Islands", "170" to "Colombia",
"174" to "Comoros", "175" to "Mayotte", "178" to "Congo", "180" to "DR Congo",
"184" to "Cook Islands", "188" to "Costa Rica", "191" to "Croatia", "192" to "Cuba",
"196" to "Cyprus", "203" to "Czechia", "204" to "Benin", "208" to "Denmark",
"212" to "Dominica", "214" to "Dominican Republic", "218" to "Ecuador", "222" to "El Salvador",
"226" to "Equatorial Guinea", "231" to "Ethiopia", "232" to "Eritrea", "233" to "Estonia",
"234" to "Faroe Islands", "238" to "Falkland Islands",
"239" to "South Georgia and the South Sandwich Islands", "242" to "Fiji", "246" to "Finland",
"248" to "Åland Islands", "250" to "France", "254" to "French Guiana", "258" to "French Polynesia",
"260" to "French Southern Territories", "262" to "Djibouti", "266" to "Gabon", "268" to "Georgia",
"270" to "Gambia", "275" to "Palestine", "276" to "Germany", "288" to "Ghana",
"292" to "Gibraltar", "296" to "Kiribati", "300" to "Greece", "304" to "Greenland",
"308" to "Grenada", "312" to "Guadeloupe", "316" to "Guam", "320" to "Guatemala",
"324" to "Guinea", "328" to "Guyana", "332" to "Haiti",
"334" to "Heard Island and McDonald Islands", "336" to "Vatican City", "340" to "Honduras",
"344" to "Hong Kong", "348" to "Hungary", "352" to "Iceland", "356" to "India",
"360" to "Indonesia", "364" to "Iran", "368" to "Iraq", "372" to "Ireland",
"376" to "Israel", "380" to "Italy", "384" to "Côte d'Ivoire", "388" to "Jamaica",
"392" to "Japan", "398" to "Kazakhstan", "400" to "Jordan", "404" to "Kenya",
"408" to "North Korea", "410" to "South Korea", "414" to "Kuwait", "417" to "Kyrgyzstan",
"418" to "Laos", "422" to "Lebanon", "426" to "Lesotho", "428" to "Latvia",
"430" to "Liberia", "434" to "Libya", "438" to "Liechtenstein", "440" to "Lithuania",
"442" to "Luxembourg", "446" to "Macau", "450" to "Madagascar", "454" to "Malawi",
"458" to "Malaysia", "462" to "Maldives", "466" to "Mali", "470" to "Malta",
"474" to "Martinique", "478" to "Mauritania", "480" to "Mauritius", "484" to "Mexico",
"492" to "Monaco", "496" to "Mongolia", "498" to "Moldova", "499" to "Montenegro",
"500" to "Montserrat", "504" to "Morocco", "508" to "Mozambique", "512" to "Oman",
"516" to "Namibia", "520" to "Nauru", "524" to "Nepal", "528" to "Netherlands",
"531" to "Curaçao", "533" to "Aruba", "534" to "Sint Maarten",
"535" to "Bonaire, Sint Eustatius and Saba", "540" to "New Caledonia", "548" to "Vanuatu",
"554" to "New Zealand", "558" to "Nicaragua", "562" to "Niger", "566" to "Nigeria",
"570" to "Niue", "574" to "Norfolk Island", "578" to "Norway",
"580" to "Northern Mariana Islands", "581" to "U.S. Minor Outlying Islands",
"583" to "Micronesia", "584" to "Marshall Islands", "585" to "Palau", "586" to "Pakistan",
"591" to "Panama", "598" to "Papua New Guinea", "600" to "Paraguay", "604" to "Peru",
"608" to "Philippines", "612" to "Pitcairn", "616" to "Poland", "620" to "Portugal",
"624" to "Guinea-Bissau", "626" to "Timor-Leste", "630" to "Puerto Rico", "634" to "Qatar",
"638" to "Réunion", "642" to "Romania", "643" to "Russia", "646" to "Rwanda",
"652" to "Saint Barthélemy", "654" to "Saint Helena", "659" to "Saint Kitts and Nevis",
"660" to "Anguilla", "662" to "Saint Lucia", "663" to "Saint Martin", "666" to "Saint Pierre and Miquelon",
"670" to "Saint Vincent and the Grenadines", "674" to "San Marino", "678" to "São Tomé and Príncipe",
"682" to "Saudi Arabia", "686" to "Senegal", "688" to "Serbia", "690" to "Seychelles",
"694" to "Sierra Leone", "702" to "Singapore", "703" to "Slovakia", "704" to "Vietnam",
"705" to "Slovenia", "706" to "Somalia", "710" to "South Africa", "716" to "Zimbabwe",
"724" to "Spain", "728" to "South Sudan", "729" to "Sudan", "732" to "Western Sahara",
"740" to "Suriname", "744" to "Svalbard and Jan Mayen", "748" to "Eswatini", "752" to "Sweden",
"756" to "Switzerland", "760" to "Syria", "762" to "Tajikistan", "764" to "Thailand",
"768" to "Togo", "772" to "Tokelau", "776" to "Tonga", "780" to "Trinidad and Tobago",
"784" to "UAE", "788" to "Tunisia", "792" to "Turkey", "795" to "Turkmenistan",
"796" to "Turks and Caicos Islands", "798" to "Tuvalu", "800" to "Uganda", "804" to "Ukraine",
"807" to "North Macedonia", "818" to "Egypt", "826" to "United Kingdom", "831" to "Guernsey",
"832" to "Jersey", "833" to "Isle of Man", "834" to "Tanzania", "840" to "United States",
"850" to "U.S. Virgin Islands", "854" to "Burkina Faso", "858" to "Uruguay", "860" to "Uzbekistan",
"862" to "Venezuela", "876" to "Wallis and Futuna", "882" to "Samoa", "887" to "Yemen",
"894" to "Zambia",
)
}
@@ -0,0 +1,359 @@
package sh.sar.sw9000.nfc
import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.nfc.tech.NfcA
/**
* Read-only contactless EMV reader that dumps everything obtainable from a card:
* the PPSE directory, every advertised application (SELECT → GPO → AFL records), a merged
* BER-TLV tree per application, a flat list of notable fields, and — for full transparency —
* the complete command/response APDU log of the whole session.
*
* It never writes to the card and runs no transaction (no GENERATE AC), so tapping is harmless
* to your own cards. [read] blocks on NFC I/O and must be called off the main thread.
*/
object EmvCardReader {
/** One command→response round-trip, kept verbatim so the UI can show the raw dump. */
data class Apdu(
val label: String,
val command: ByteArray,
val response: ByteArray,
val sw1: Int,
val sw2: Int,
) {
val sw: String get() = "%02X%02X".format(sw1, sw2)
val ok: Boolean get() = sw1 == 0x90 && sw2 == 0x00
val swMeaning: String get() = StatusWords.describe(sw1, sw2)
}
/** Everything learned about a single application (AID). */
data class AppResult(
val aid: String,
val label: String?,
val preferredName: String?,
val tlv: List<TlvNode>,
)
/** A single labelled value for the "formatted" view. */
data class Field(val name: String, val value: String)
/** The full outcome of a tap. [error] is set only when nothing usable could be read. */
data class Result(
val uid: String,
val techList: List<String>,
val historicalBytes: String?,
val hiLayerResponse: String?,
val atqa: String?,
val sak: String?,
val apps: List<AppResult>,
val fields: List<Field>,
val apduLog: List<Apdu>,
val error: String?,
) {
val hasData: Boolean get() = apps.isNotEmpty() || fields.isNotEmpty()
}
// Fallback AID list if the PPSE directory is empty or absent.
private val KNOWN_AIDS = listOf(
"A0000000031010" to "Visa",
"A0000000032010" to "Visa Electron",
"A0000000041010" to "Mastercard",
"A0000000043060" to "Maestro",
"A00000002501" to "American Express",
"A0000003330101" to "UnionPay",
"A0000001523010" to "Discover",
"A0000000651010" to "JCB",
"A0000006200620" to "Interac",
)
fun read(tag: Tag): Result {
val log = ArrayList<Apdu>()
val iso = IsoDep.get(tag)
?: return Result(
uid = Hex.encode(tag.id),
techList = tag.techList.toList(),
historicalBytes = null, hiLayerResponse = null,
atqa = nfcA(tag)?.let { Hex.encode(it.atqa) },
sak = nfcA(tag)?.let { "%02X".format(it.sak) },
apps = emptyList(), fields = emptyList(), apduLog = emptyList(),
error = "Card does not support ISO-DEP (IsoDep). It may not be an EMV payment card.",
)
val apps = ArrayList<AppResult>()
var error: String? = null
try {
iso.connect()
iso.timeout = 5000
// 1) PPSE — the payment application directory.
val ppse = exchange(iso, selectName("2PAY.SYS.DDF01"), "SELECT PPSE (2PAY.SYS.DDF01)", log)
val aidsFromPpse = ppse?.let { Tlv.parse(it) }?.let { collectAids(it) } ?: emptyList()
val candidates = if (aidsFromPpse.isNotEmpty()) aidsFromPpse else KNOWN_AIDS
// 2) Walk each candidate application.
for ((aidHex, hint) in candidates) {
val aid = Hex.decode(aidHex)
val fci = exchange(iso, selectAid(aid), "SELECT AID $aidHex${hint?.let { " ($it)" } ?: ""}", log)
?: continue
val fciTlv = Tlv.parse(fci)
val merged = ArrayList<TlvNode>().apply { addAll(fciTlv) }
// 3) GET PROCESSING OPTIONS with the card's PDOL filled in.
val pdol = Tlv.find(fciTlv, "9F38")
val gpo = exchange(iso, gpoApdu(pdol), "GET PROCESSING OPTIONS", log)
if (gpo != null) {
val gpoTlv = Tlv.parse(gpo)
merged.addAll(gpoTlv)
// 4) Read every record named by the AFL.
val afl = aflFrom(gpoTlv)
var i = 0
while (i + 3 < afl.size) {
val sfi = (afl[i].toInt() and 0xFF) shr 3
val first = afl[i + 1].toInt() and 0xFF
val last = afl[i + 2].toInt() and 0xFF
for (rec in first..last) {
val data = exchange(iso, readRecord(sfi, rec), "READ RECORD SFI $sfi rec $rec", log)
if (data != null) merged.addAll(Tlv.parse(data))
}
i += 4
}
}
// 5) GET DATA for the common counters (harmless, read-only).
for ((t, name) in listOf("9F36" to "ATC", "9F13" to "Last Online ATC", "9F17" to "PIN Try Counter")) {
val d = exchange(iso, getData(t), "GET DATA $t ($name)", log)
if (d != null) merged.addAll(Tlv.parse(d))
}
apps.add(
AppResult(
aid = aidHex,
label = Tlv.find(merged, "50")?.let { asciiOrNull(it) } ?: hint,
preferredName = Tlv.find(merged, "9F12")?.let { asciiOrNull(it) },
tlv = dedupe(merged),
)
)
}
if (apps.isEmpty() && ppse == null) {
error = "No EMV payment application responded. The card may use a scheme this tool " +
"doesn't probe, or it isn't a contactless payment card."
}
} catch (e: Exception) {
error = "Read error: ${e.message ?: e.javaClass.simpleName}"
} finally {
runCatching { iso.close() }
}
val fields = buildFields(apps)
return Result(
uid = Hex.encode(tag.id),
techList = tag.techList.toList(),
historicalBytes = iso.historicalBytes?.takeIf { it.isNotEmpty() }?.let { Hex.encode(it) },
hiLayerResponse = iso.hiLayerResponse?.takeIf { it.isNotEmpty() }?.let { Hex.encode(it) },
atqa = nfcA(tag)?.let { Hex.encode(it.atqa) },
sak = nfcA(tag)?.let { "%02X".format(it.sak) },
apps = apps,
fields = fields,
apduLog = log,
error = if (apps.isEmpty()) (error ?: "Couldn't read any card data.") else null,
)
}
// ── Field extraction for the formatted view ────────────────────────────────
private fun buildFields(apps: List<AppResult>): List<Field> {
val out = LinkedHashMap<String, String>()
fun put(name: String, value: String?) { if (!value.isNullOrBlank() && !out.containsKey(name)) out[name] = value }
for (app in apps) {
val t = app.tlv
// PAN: prefer 5A, fall back to Track 2.
val pan = Tlv.find(t, "5A")?.let { Hex.encode(it).trimEnd('F') }
?: Tlv.find(t, "57")?.let { trackPan(it) }
put("Card Number (PAN)", pan?.chunked(4)?.joinToString(" "))
put("Card Scheme", CardIdentity.scheme(app.aid, pan))
pan?.filter { it.isDigit() }?.takeIf { it.length >= 6 }
?.let { put("BIN (IIN) — look up issuer", it.take(8)) }
// Issuer bank/type/brand come only from a bundled BIN database (if present); the
// card itself doesn't carry them. These fill gaps without overriding card-read values.
BinLookup.lookup(pan)?.let { iss ->
put("Card Scheme", iss.scheme)
put("Issuer Bank", iss.bank)
put("Card Type", iss.type)
put("Card Product", iss.brand)
}
put("Cardholder Name", Tlv.find(t, "5F20")?.let { asciiOrNull(it) })
Tlv.find(t, "5F24")?.let { put("Expiry Date", expiry(it)) }
?: Tlv.find(t, "57")?.let { put("Expiry Date", trackExpiry(it)) }
put("Valid From", Tlv.find(t, "5F25")?.let { effective(it) })
put("PAN Sequence Number", Tlv.find(t, "5F34")?.let { Hex.encode(it) })
put("Application Label", app.label)
put("Preferred Name", app.preferredName)
put("AID", app.aid)
put("Issuer Country", Tlv.find(t, "5F28")?.let { CardIdentity.country(Hex.encode(it)) }
?: BinLookup.lookup(pan)?.country)
put("Application Currency", Tlv.find(t, "9F42")?.let { TlvInterpreter.interpret("9F42", it) })
put("Language Preference", Tlv.find(t, "5F2D")?.let { asciiOrNull(it) })
put("Application Version", Tlv.find(t, "9F08")?.let { Hex.encode(it) })
put("ATC (Transaction Counter)", Tlv.find(t, "9F36")?.let { beInt(it).toString() })
put("Last Online ATC", Tlv.find(t, "9F13")?.let { beInt(it).toString() })
put("PIN Try Counter", Tlv.find(t, "9F17")?.let { beInt(it).toString() })
put("Application Interchange Profile", Tlv.find(t, "82")?.let { TlvInterpreter.interpret("82", it) })
put("Track 2 Equivalent", Tlv.find(t, "57")?.let { Hex.encode(it) })
}
return out.map { Field(it.key, it.value) }
}
// ── APDU builders ──────────────────────────────────────────────────────────
private fun selectName(name: String): ByteArray = selectAid(name.toByteArray(Charsets.US_ASCII))
private fun selectAid(aid: ByteArray): ByteArray =
byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, aid.size.toByte()) + aid + byteArrayOf(0x00)
private fun readRecord(sfi: Int, rec: Int): ByteArray =
byteArrayOf(0x00, 0xB2.toByte(), rec.toByte(), ((sfi shl 3) or 0x04).toByte(), 0x00)
private fun getData(tag: String): ByteArray {
val t = Hex.decode(tag)
return byteArrayOf(0x80.toByte(), 0xCA.toByte(), t[0], t.getOrElse(1) { 0 }, 0x00)
}
/** Builds GPO, filling the PDOL with sensible terminal values (zeros where unknown). */
private fun gpoApdu(pdol: ByteArray?): ByteArray {
val data = ArrayList<Byte>()
if (pdol != null) {
var i = 0
while (i < pdol.size) {
var tag = pdol[i].toInt() and 0xFF
i++
if (tag and 0x1F == 0x1F) {
while (i < pdol.size) {
val b = pdol[i++].toInt() and 0xFF
tag = (tag shl 8) or b
if (b and 0x80 == 0) break
}
}
if (i >= pdol.size) break
val len = pdol[i++].toInt() and 0xFF
val value = pdolValue(tag, len)
data.addAll(value.copyOf(len).toList())
}
}
val pdolData = data.toByteArray()
val body = byteArrayOf(0x83.toByte(), pdolData.size.toByte()) + pdolData
return byteArrayOf(0x80.toByte(), 0xA8.toByte(), 0x00, 0x00, body.size.toByte()) + body + byteArrayOf(0x00)
}
private fun pdolValue(tag: Int, len: Int): ByteArray = when (tag) {
0x9F66 -> Hex.decode("B6204000") // TTQ: qVSDC, online-capable, no CVM required
0x9F1A -> Hex.decode("0462") // Terminal country: Maldives
0x5F2A -> Hex.decode("0462") // Transaction currency: MVR
0x9A -> Hex.decode("260101") // Transaction date (YYMMDD)
0x9C -> Hex.decode("00") // Transaction type: purchase
0x9F37 -> Hex.decode("12345678") // Unpredictable number
0x9F35 -> Hex.decode("22") // Terminal type
0x9F33 -> Hex.decode("6008C8") // Terminal capabilities
0x9F40 -> Hex.decode("F000F0A001") // Additional terminal capabilities
0x9F02 -> Hex.decode("000000000100") // Amount authorised: 1 minor unit
0x9F03 -> Hex.decode("000000000000") // Amount other
0x95 -> Hex.decode("0000000000") // TVR
else -> ByteArray(len)
}
// ── Transceive with 61xx / 6Cxx handling ───────────────────────────────────
private fun exchange(iso: IsoDep, apdu: ByteArray, label: String, log: MutableList<Apdu>): ByteArray? {
var cmd = apdu
var resp = iso.transceive(cmd)
if (resp.size < 2) { log.add(Apdu(label, cmd, resp, 0, 0)); return null }
var sw1 = resp[resp.size - 2].toInt() and 0xFF
var sw2 = resp[resp.size - 1].toInt() and 0xFF
if (sw1 == 0x6C) { // wrong Le; retry with the length the card wants
cmd = apdu.copyOf()
cmd[cmd.size - 1] = resp[resp.size - 1]
resp = iso.transceive(cmd)
sw1 = resp[resp.size - 2].toInt() and 0xFF
sw2 = resp[resp.size - 1].toInt() and 0xFF
}
// Accumulate any GET RESPONSE (61xx) continuations.
var payload = resp.copyOf(resp.size - 2)
while (sw1 == 0x61) {
val getResp = byteArrayOf(0x00, 0xC0.toByte(), 0x00, 0x00, resp[resp.size - 1])
resp = iso.transceive(getResp)
if (resp.size < 2) break
payload += resp.copyOf(resp.size - 2)
sw1 = resp[resp.size - 2].toInt() and 0xFF
sw2 = resp[resp.size - 1].toInt() and 0xFF
}
log.add(Apdu(label, cmd, payload + byteArrayOf(sw1.toByte(), sw2.toByte()), sw1, sw2))
return if (sw1 == 0x90 && sw2 == 0x00) payload else null
}
// ── Small helpers ──────────────────────────────────────────────────────────
private fun nfcA(tag: Tag): NfcA? = runCatching { NfcA.get(tag) }.getOrNull()
private fun collectAids(nodes: List<TlvNode>): List<Pair<String, String?>> {
val out = ArrayList<Pair<String, String?>>()
fun walk(ns: List<TlvNode>) {
for (n in ns) {
if (n.tag == "4F") out.add(Hex.encode(n.value) to null)
walk(n.children)
}
}
walk(nodes)
return out
}
private fun aflFrom(gpoTlv: List<TlvNode>): ByteArray =
Tlv.find(gpoTlv, "94")
?: Tlv.find(gpoTlv, "80")?.let { if (it.size > 2) it.copyOfRange(2, it.size) else null }
?: ByteArray(0)
/** Removes duplicate leaf tags (same tag+value) that appear across records. */
private fun dedupe(nodes: List<TlvNode>): List<TlvNode> {
val seen = HashSet<String>()
val out = ArrayList<TlvNode>()
for (n in nodes) {
val key = n.tag + ":" + if (n.constructed) "C" else Hex.encode(n.value)
if (seen.add(key)) out.add(n)
}
return out
}
private fun asciiOrNull(v: ByteArray): String? =
String(v, Charsets.US_ASCII).trim().filter { it.code in 32..126 }.ifBlank { null }
private fun beInt(v: ByteArray): Int = v.fold(0) { a, b -> (a shl 8) or (b.toInt() and 0xFF) }
private fun trackPan(track2: ByteArray): String {
val h = Hex.encode(track2).uppercase()
val sep = h.indexOfFirst { it == 'D' || it == '=' }
return if (sep > 0) h.substring(0, sep) else h
}
private fun trackExpiry(track2: ByteArray): String {
val h = Hex.encode(track2).uppercase()
val sep = h.indexOfFirst { it == 'D' || it == '=' }
if (sep < 0) return ""
val yymm = h.substring(sep + 1).take(4)
return if (yymm.length == 4) "${yymm.substring(2, 4)}/${yymm.substring(0, 2)}" else ""
}
private fun expiry(v: ByteArray): String {
val h = Hex.encode(v)
return if (h.length >= 4) "${h.substring(2, 4)}/${h.substring(0, 2)}" else h
}
private fun effective(v: ByteArray): String {
val h = Hex.encode(v)
return if (h.length >= 4) "${h.substring(2, 4)}/${h.substring(0, 2)}" else h
}
}
@@ -0,0 +1,114 @@
package sh.sar.sw9000.nfc
/**
* Dictionary of EMV / ISO 7816 BER-TLV tags, used to label every object pulled off the card.
* Names follow the EMV 4.x specification and common issuer usage. Unknown tags fall back to
* "Unknown (<tag>)" so nothing read from the card is ever silently dropped.
*/
object EmvTags {
private val names: Map<String, String> = mapOf(
// ── Templates / FCI ──────────────────────────────────────────────────
"6F" to "FCI Template",
"84" to "Dedicated File (DF) Name / AID",
"A5" to "FCI Proprietary Template",
"87" to "Application Priority Indicator",
"88" to "Short File Identifier (SFI)",
"5F2D" to "Language Preference",
"9F11" to "Issuer Code Table Index",
"9F12" to "Application Preferred Name",
"50" to "Application Label",
"9F38" to "Processing Options Data Object List (PDOL)",
"BF0C" to "FCI Issuer Discretionary Data",
"61" to "Application Template",
"4F" to "Application Identifier (AID)",
"73" to "Directory Discretionary Template",
// ── PPSE ─────────────────────────────────────────────────────────────
"DF60" to "Kernel Identifier (proprietary)",
"9F2A" to "Kernel Identifier",
"9F0A" to "Application Selection Registered Proprietary Data",
// ── GPO / processing ─────────────────────────────────────────────────
"80" to "Response Message Template Format 1",
"77" to "Response Message Template Format 2",
"82" to "Application Interchange Profile (AIP)",
"94" to "Application File Locator (AFL)",
"83" to "Command Template",
"9F66" to "Terminal Transaction Qualifiers (TTQ)",
// ── Cardholder / account ─────────────────────────────────────────────
"5A" to "Application Primary Account Number (PAN)",
"5F34" to "PAN Sequence Number",
"5F20" to "Cardholder Name",
"5F24" to "Application Expiration Date",
"5F25" to "Application Effective Date",
"5F28" to "Issuer Country Code",
"5F2A" to "Transaction Currency Code",
"9F42" to "Application Currency Code",
"9F44" to "Application Currency Exponent",
"5F30" to "Service Code",
"5F36" to "Transaction Currency Exponent",
"57" to "Track 2 Equivalent Data",
"9F6B" to "Track 2 Data",
"56" to "Track 1 Equivalent Data",
"9F1F" to "Track 1 Discretionary Data",
"9F20" to "Track 2 Discretionary Data",
"9F5B" to "Issuer Script Results",
// ── Application control / counters ───────────────────────────────────
"8C" to "Card Risk Management Data Object List 1 (CDOL1)",
"8D" to "Card Risk Management Data Object List 2 (CDOL2)",
"8E" to "Cardholder Verification Method (CVM) List",
"8F" to "Certification Authority Public Key Index",
"90" to "Issuer Public Key Certificate",
"92" to "Issuer Public Key Remainder",
"93" to "Signed Static Application Data",
"9F32" to "Issuer Public Key Exponent",
"9F46" to "ICC Public Key Certificate",
"9F47" to "ICC Public Key Exponent",
"9F48" to "ICC Public Key Remainder",
"9F49" to "Dynamic Data Authentication Data Object List (DDOL)",
"9F4A" to "Static Data Authentication Tag List",
"9F4B" to "Signed Dynamic Application Data",
"9F4C" to "ICC Dynamic Number",
"9F62" to "Track 1 bit map for PCII (PAN/Seq)",
"9F63" to "Track 2 bit map for PCII",
"9F64" to "Track 1 Number of ATC Digits",
"9F65" to "Track 2 bit map for CVC3",
"9F67" to "Track 2 Number of ATC Digits / NATC",
"9F68" to "Mag-stripe CVM List",
"9F69" to "Card Authentication Related Data (UDOL)",
"9F6C" to "Card Transaction Qualifiers (CTQ)",
"9F6E" to "Form Factor Indicator / 3rd Party Data",
// ── Counters read via GET DATA ───────────────────────────────────────
"9F36" to "Application Transaction Counter (ATC)",
"9F13" to "Last Online ATC Register",
"9F17" to "PIN Try Counter",
"9F4F" to "Log Format",
"9F4D" to "Log Entry",
// ── Issuer / misc ────────────────────────────────────────────────────
"9F07" to "Application Usage Control (AUC)",
"9F08" to "Application Version Number",
"9F0D" to "Issuer Action Code - Default",
"9F0E" to "Issuer Action Code - Denial",
"9F0F" to "Issuer Action Code - Online",
"9F5A" to "Application Program Identifier",
"9F10" to "Issuer Application Data",
"9F26" to "Application Cryptogram",
"9F27" to "Cryptogram Information Data",
"9F34" to "Cardholder Verification Method (CVM) Results",
"9F37" to "Unpredictable Number",
"9F35" to "Terminal Type",
"9F33" to "Terminal Capabilities",
"9F1A" to "Terminal Country Code",
"9F40" to "Additional Terminal Capabilities",
"95" to "Terminal Verification Results (TVR)",
"9A" to "Transaction Date",
"9C" to "Transaction Type",
"9F02" to "Amount, Authorised (Numeric)",
"9F03" to "Amount, Other (Numeric)",
"9F21" to "Transaction Time",
"9F7C" to "Customer Exclusive Data",
"BF50" to "Issuer Discretionary Data",
"DF810C" to "Kernel Identifier",
)
fun name(tag: String): String = names[tag.uppercase()] ?: "Unknown (0x$tag)"
}
@@ -0,0 +1,39 @@
package sh.sar.sw9000.nfc
/** Human-readable meaning of ISO 7816 / EMV status words (SW1 SW2), for the raw APDU dump. */
object StatusWords {
fun describe(sw1: Int, sw2: Int): String {
val sw = (sw1 shl 8) or sw2
exact[sw]?.let { return it }
return when (sw1) {
0x61 -> "$sw2 bytes available (GET RESPONSE)"
0x6C -> "Wrong Le; $sw2 bytes expected"
0x63 -> if (sw2 in 0xC0..0xCF) "Verification failed, ${sw2 and 0x0F} tries left" else "Warning"
0x62 -> "Warning: state unchanged"
0x64 -> "Error: state unchanged"
0x65 -> "Error: state changed (memory)"
0x67 -> "Wrong length"
0x6A -> "Wrong parameters P1-P2"
else -> "Unknown status"
}
}
private val exact = mapOf(
0x9000 to "Success",
0x6700 to "Wrong length",
0x6283 to "Selected file invalidated",
0x6982 to "Security status not satisfied",
0x6983 to "Authentication method blocked",
0x6985 to "Conditions of use not satisfied",
0x6A80 to "Incorrect data in command",
0x6A81 to "Function not supported",
0x6A82 to "File or application not found",
0x6A83 to "Record not found",
0x6A84 to "Not enough memory space",
0x6A86 to "Incorrect P1-P2",
0x6A88 to "Referenced data not found",
0x6D00 to "Instruction not supported",
0x6E00 to "Class not supported",
0x6F00 to "No precise diagnosis",
)
}
+207
View File
@@ -0,0 +1,207 @@
package sh.sar.sw9000.nfc
/**
* BER-TLV parsing for EMV responses, plus best-effort human interpretation of each value.
* The parser is tolerant: it stops cleanly at padding (00 / FF) and never throws on a truncated
* object, so a partial read still yields whatever objects were decodable.
*/
/** One decoded TLV object. [children] is non-empty only for constructed tags. */
data class TlvNode(
val tag: String, // uppercase hex, e.g. "5F24"
val length: Int,
val value: ByteArray, // raw value bytes (empty for constructed tags we recursed into)
val constructed: Boolean,
val children: List<TlvNode>,
) {
val name: String get() = EmvTags.name(tag)
val valueHex: String get() = Hex.encode(value)
/** A friendly rendering of the value where the tag's meaning is known; null if hex is best. */
val interpreted: String? get() = TlvInterpreter.interpret(tag, value)
}
object Tlv {
/** Parses [data] as a sequence of BER-TLV objects, recursing into constructed templates. */
fun parse(data: ByteArray): List<TlvNode> = parse(data, 0, data.size)
private fun parse(data: ByteArray, start: Int, end: Int): List<TlvNode> {
val out = ArrayList<TlvNode>()
var i = start
while (i < end) {
val b0 = data[i].toInt() and 0xFF
if (b0 == 0x00 || b0 == 0xFF) { i++; continue } // inter-object padding
val tagStart = i
val constructed = b0 and 0x20 != 0
i++
if (b0 and 0x1F == 0x1F) { // multi-byte tag
while (i < end) {
val b = data[i++].toInt() and 0xFF
if (b and 0x80 == 0) break
}
}
if (i >= end) break
val tag = Hex.encode(data.copyOfRange(tagStart, i))
var len = data[i++].toInt() and 0xFF
if (len and 0x80 != 0) { // multi-byte length
val n = len and 0x7F
if (n == 0 || i + n > end) break
len = 0
repeat(n) { len = (len shl 8) or (data[i++].toInt() and 0xFF) }
}
if (len < 0 || i + len > end) break
val value = data.copyOfRange(i, i + len)
val children = if (constructed) parse(value, 0, value.size) else emptyList()
out.add(TlvNode(tag, len, if (constructed) ByteArray(0) else value, constructed, children))
i += len
}
return out
}
/** Finds the first value for [tag] anywhere in the tree (depth-first). */
fun find(nodes: List<TlvNode>, tag: String): ByteArray? {
val t = tag.uppercase()
for (n in nodes) {
if (n.tag == t && !n.constructed) return n.value
val c = find(n.children, t)
if (c != null) return c
}
return null
}
}
/** Turns known EMV values into readable text. Returns null when raw hex is the clearest form. */
object TlvInterpreter {
fun interpret(tag: String, v: ByteArray): String? = when (tag.uppercase()) {
"5A" -> "PAN " + digitsBcd(v).trimEnd('F').chunked(4).joinToString(" ")
"5F34" -> "Sequence #" + digitsBcd(v)
"5F20" -> ascii(v)
"50", "9F12" -> ascii(v)
"5F2D" -> ascii(v)
"5F24" -> expiryDate(v)
"5F25" -> effectiveDate(v)
"5F28" -> "Country " + countryName(digitsBcd(v))
"9F1A" -> "Country " + countryName(digitsBcd(v))
"5F2A", "9F42" -> "Currency " + currencyName(digitsBcd(v))
"57" -> track2(v)
"9F6B" -> track2(v)
"56" -> ascii(v)
"82" -> "AIP " + Hex.encode(v) + aipBits(v)
"94" -> "AFL " + aflText(v)
"87" -> "Priority " + (v.firstOrNull()?.toInt()?.and(0x0F) ?: 0)
"88" -> "SFI " + (v.firstOrNull()?.toInt()?.and(0xFF) ?: 0)
"9F36", "9F13", "9F17" -> (v.fold(0) { a, b -> (a shl 8) or (b.toInt() and 0xFF) }).toString()
"9F08", "9F09" -> "v" + Hex.encode(v)
"9A" -> "Date " + bcdDate(v)
"9F21" -> "Time " + bcdTime(v)
"9C" -> transactionType(v)
"5F30" -> "Service code " + digitsBcd(v)
else -> null
}?.takeIf { it.isNotBlank() }
private fun ascii(v: ByteArray): String =
String(v, Charsets.US_ASCII).trim().filter { it.code in 32..126 }
private fun digitsBcd(v: ByteArray): String = Hex.encode(v)
private fun expiryDate(v: ByteArray): String {
val h = Hex.encode(v)
if (h.length < 4) return h
return "Expires ${h.substring(2, 4)}/${h.substring(0, 2)}" // MM/YY from YYMMDD
}
private fun effectiveDate(v: ByteArray): String {
val h = Hex.encode(v)
if (h.length < 4) return h
return "Valid from ${h.substring(2, 4)}/${h.substring(0, 2)}"
}
private fun bcdDate(v: ByteArray): String {
val h = Hex.encode(v)
return if (h.length >= 6) "20${h.substring(0, 2)}-${h.substring(2, 4)}-${h.substring(4, 6)}" else h
}
private fun bcdTime(v: ByteArray): String {
val h = Hex.encode(v)
return if (h.length >= 6) "${h.substring(0, 2)}:${h.substring(2, 4)}:${h.substring(4, 6)}" else h
}
private fun track2(v: ByteArray): String {
val h = Hex.encode(v).uppercase()
val sep = h.indexOfFirst { it == 'D' || it == '=' }
if (sep <= 0) return h
val pan = h.substring(0, sep)
val rest = h.substring(sep + 1)
val exp = rest.take(4)
val svc = rest.drop(4).take(3)
val expShown = if (exp.length == 4) "exp ${exp.substring(2, 4)}/${exp.substring(0, 2)}" else ""
return "PAN ${pan.chunked(4).joinToString(" ")} $expShown svc $svc"
}
private fun aipBits(v: ByteArray): String {
if (v.isEmpty()) return ""
val b1 = v[0].toInt() and 0xFF
val flags = buildList {
if (b1 and 0x40 != 0) add("SDA")
if (b1 and 0x20 != 0) add("DDA")
if (b1 and 0x10 != 0) add("Cardholder verification")
if (b1 and 0x08 != 0) add("Terminal risk mgmt")
if (b1 and 0x04 != 0) add("Issuer auth")
if (b1 and 0x01 != 0) add("CDA")
}
return if (flags.isEmpty()) "" else " (" + flags.joinToString(", ") + ")"
}
private fun aflText(v: ByteArray): String {
val sb = StringBuilder()
var i = 0
while (i + 3 < v.size) {
val sfi = (v[i].toInt() and 0xFF) shr 3
val first = v[i + 1].toInt() and 0xFF
val last = v[i + 2].toInt() and 0xFF
if (sb.isNotEmpty()) sb.append("; ")
sb.append("SFI $sfi rec $first-$last")
i += 4
}
return sb.toString()
}
private fun transactionType(v: ByteArray): String = when (Hex.encode(v)) {
"00" -> "Purchase"
"01" -> "Cash"
"09" -> "Purchase with cashback"
"20" -> "Refund"
else -> Hex.encode(v)
}
private fun countryName(code: String): String = CardIdentity.country(code)
private fun currencyName(code: String): String = CURRENCY[code.trimStart('0').padStart(3, '0')] ?: code
private val CURRENCY = mapOf(
"462" to "MVR (462)", "840" to "USD (840)", "978" to "EUR (978)",
"826" to "GBP (826)", "356" to "INR (356)", "702" to "SGD (702)",
"784" to "AED (784)", "036" to "AUD (036)", "124" to "CAD (124)",
)
}
/** Hex helpers shared across the NFC layer. */
object Hex {
fun encode(b: ByteArray): String {
val sb = StringBuilder(b.size * 2)
for (x in b) sb.append(HEX[(x.toInt() ushr 4) and 0xF]).append(HEX[x.toInt() and 0xF])
return sb.toString()
}
fun decode(s: String): ByteArray {
val clean = s.filter { !it.isWhitespace() }
return ByteArray(clean.length / 2) { clean.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
}
/** Space-grouped hex for readable dumps, e.g. "00 A4 04 00". */
fun pretty(b: ByteArray): String = b.joinToString(" ") { "%02X".format(it) }
private const val HEX = "0123456789ABCDEF"
}
@@ -0,0 +1,291 @@
package sh.sar.sw9000.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.withFrameMillis
import androidx.compose.runtime.LaunchedEffect
import kotlin.math.PI
import kotlin.math.min
import kotlin.math.sin
/** What the reader is doing; the animation reflects it. */
enum class TapState { WAITING, READING, SUCCESS, ERROR }
/**
* "Tap your card" animation: a bank card swings onto the back of a phone, NFC waves ripple out
* from the antenna, then it lifts away and repeats. In READING the waves pulse continuously;
* SUCCESS pops a check badge; ERROR shakes the card. Faithful to the reference app's artwork.
*/
@Composable
fun NfcCardAnimation(
state: TapState,
label: String,
modifier: Modifier = Modifier,
) {
val cs = MaterialTheme.colorScheme
var now by remember { mutableLongStateOf(0L) }
var stateStart by remember { mutableLongStateOf(0L) }
// Drives redraws every frame.
LaunchedEffect(Unit) {
while (true) withFrameMillis { now = it }
}
// Resets the animation clock each time the state changes (and on first show).
LaunchedEffect(state) {
withFrameMillis { stateStart = it; now = it }
}
val elapsed = (now - stateStart).coerceAtLeast(0L)
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
) {
drawScene(state, elapsed, cs.primary, cs.onSurface, cs.surfaceVariant, cs.error, cs.onPrimary)
}
Text(
text = label,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, start = 24.dp, end = 24.dp),
textAlign = TextAlign.Center,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = if (state == TapState.ERROR) cs.error else cs.onSurface,
),
)
}
}
private const val DESIGN_W = 240f
private const val DESIGN_H = 170f
private const val CYCLE_MS = 2600f
private fun DrawScope.drawScene(
state: TapState,
elapsed: Long,
primary: Color,
onSurface: Color,
surfaceVariant: Color,
error: Color,
onPrimary: Color,
) {
val w = size.width
val h = size.height
if (w <= 0f || h <= 0f) return
val scale = min(min(w / DESIGN_W, h / DESIGN_H), density * 1.1f).coerceAtLeast(0.6f)
val u = scale
val cx = w / 2f
val top = (h - DESIGN_H * u) / 2f
// Card contact: 0 = away, 1 = held on phone.
val contact = when (state) {
TapState.WAITING -> {
val p = (elapsed % CYCLE_MS.toLong()) / CYCLE_MS
when {
p < 0.35f -> easeInOut(p / 0.35f)
p < 0.70f -> 1f
p < 1.00f -> 1f - easeInOut((p - 0.70f) / 0.30f)
else -> 0f
}
}
else -> 1f
}
val shake = if (state == TapState.ERROR && elapsed < 500)
(sin(elapsed / 500f * 6 * PI).toFloat() * (1f - elapsed / 500f) * 8 * u) else 0f
// ── Phone (back view) ──
val phoneW = 64 * u; val phoneH = 112 * u
val phoneL = cx - phoneW / 2f; val phoneT = top + 44 * u
drawRoundRect(
color = surfaceVariant,
topLeft = Offset(phoneL, phoneT),
size = Size(phoneW, phoneH),
cornerRadius = corner(10 * u),
)
drawRoundRect(
color = onSurface,
topLeft = Offset(phoneL, phoneT),
size = Size(phoneW, phoneH),
cornerRadius = corner(10 * u),
style = Stroke(width = 2.5f * u),
)
drawRoundRect(
color = onSurface.copy(alpha = 0.24f),
topLeft = Offset(phoneL + 8 * u, phoneT + 8 * u),
size = Size(18 * u, 22 * u),
cornerRadius = corner(5 * u),
)
val touchX = cx
val touchY = phoneT + phoneH * 0.42f
// ── NFC waves ──
val waveStrength = when (state) {
TapState.WAITING -> ((contact - 0.85f) / 0.15f).coerceIn(0f, 1f)
TapState.READING -> 1f
else -> 0f
}
if (waveStrength > 0f) {
val period = if (state == TapState.READING) 700f else 1100f
val base = (elapsed % period.toLong()) / period
for (i in 0..2) {
val p = (base + i / 3f) % 1f
val r = 58 * u + p * 46 * u
drawArcOval(
center = Offset(touchX, touchY),
rx = r, ry = r * 0.72f,
color = primary.copy(alpha = (1f - p) * 0.85f * waveStrength),
stroke = 3 * u,
)
}
}
// ── Card ──
val cardW = 104 * u; val cardH = 66 * u
val restX = cx + 58 * u; val restY = top + 48 * u
val cardCx = restX + (touchX - restX) * contact + shake
val cardCy = restY + (touchY - restY) * contact
val rotation = 18f * (1f - contact)
val lift = 1f + 0.08f * (1f - contact)
translate(cardCx, cardCy) {
rotate(rotation, pivot = Offset.Zero) {
scale(lift, lift, pivot = Offset.Zero) {
val outline = if (state == TapState.ERROR) error else onSurface
val left = -cardW / 2; val tp = -cardH / 2
drawRoundRect(
color = surfaceVariant,
topLeft = Offset(left, tp),
size = Size(cardW, cardH),
cornerRadius = corner(8 * u),
)
drawRoundRect(
color = outline,
topLeft = Offset(left, tp),
size = Size(cardW, cardH),
cornerRadius = corner(8 * u),
style = Stroke(width = 2.5f * u),
)
// Chip
drawRoundRect(
color = primary.copy(alpha = 0.28f),
topLeft = Offset(left + 12 * u, -9 * u),
size = Size(18 * u, 14 * u),
cornerRadius = corner(3 * u),
)
drawRoundRect(
color = outline,
topLeft = Offset(left + 12 * u, -9 * u),
size = Size(18 * u, 14 * u),
cornerRadius = corner(3 * u),
style = Stroke(width = 1.5f * u),
)
// Contactless arcs on the card
for (i in 0..2) {
val r = (5 + i * 4.5f) * u
val arcCx = cardW / 2 - 28 * u
drawArc(
color = outline,
startAngle = -45f,
sweepAngle = 90f,
useCenter = false,
topLeft = Offset(arcCx - r, -14 * u - r),
size = Size(r * 2, r * 2),
style = Stroke(width = 1.8f * u, cap = StrokeCap.Round),
)
}
// Number + name placeholders
for (g in 0..3) {
val x = left + 12 * u + g * 21 * u
drawLine(outline.copy(alpha = 0.6f), Offset(x, 16 * u), Offset(x + 15 * u, 16 * u), 3f * u)
}
drawLine(outline.copy(alpha = 0.4f), Offset(left + 12 * u, 26 * u), Offset(left + 48 * u, 26 * u), 2.5f * u)
}
}
}
// ── Success badge ──
if (state == TapState.SUCCESS) {
val t = (elapsed / 450f).coerceIn(0f, 1f)
val badgeR = 22 * u * overshoot(t)
val bx = touchX + cardW / 2 - 6 * u; val by = touchY - cardH / 2 + 4 * u
drawCircle(primary, badgeR, Offset(bx, by))
val checkT = ((elapsed - 200) / 350f).coerceIn(0f, 1f)
if (checkT > 0f) {
val x0 = bx - 9 * u; val y0 = by
val x1 = bx - 3 * u; val y1 = by + 7 * u
val x2 = bx + 10 * u; val y2 = by - 7 * u
val path = Path().apply {
moveTo(x0, y0)
if (checkT < 0.4f) {
val k = checkT / 0.4f
lineTo(x0 + (x1 - x0) * k, y0 + (y1 - y0) * k)
} else {
val k = (checkT - 0.4f) / 0.6f
lineTo(x1, y1); lineTo(x1 + (x2 - x1) * k, y1 + (y2 - y1) * k)
}
}
drawPath(path, onPrimary, style = Stroke(width = 3.5f * u, cap = StrokeCap.Round))
}
}
}
private fun DrawScope.drawArcOval(center: Offset, rx: Float, ry: Float, color: Color, stroke: Float) {
drawArc(
color = color,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
topLeft = Offset(center.x - rx, center.y - ry),
size = Size(rx * 2, ry * 2),
style = Stroke(width = stroke),
)
}
private fun DrawScope.corner(r: Float) = androidx.compose.ui.geometry.CornerRadius(r, r)
private fun easeInOut(x: Float): Float {
val c = x.coerceIn(0f, 1f)
return -(kotlin.math.cos(PI * c).toFloat() - 1f) / 2f
}
private fun overshoot(x: Float, tension: Float = 2.2f): Float {
val t = x.coerceIn(0f, 1f) - 1f
return t * t * ((tension + 1) * t + tension) + 1f
}
@@ -0,0 +1,257 @@
package sh.sar.sw9000.ui
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import sh.sar.sw9000.nfc.EmvCardReader
import sh.sar.sw9000.nfc.Hex
import sh.sar.sw9000.nfc.TlvNode
/** NFC hardware isn't present on this device. */
@Composable
fun UnsupportedScreen(modifier: Modifier = Modifier) {
CenteredMessage(
modifier = modifier,
title = "NFC not supported",
body = "This device has no NFC hardware, so it can't read contactless cards. " +
"Run sw9000 on a phone with NFC.",
)
}
/** NFC exists but is turned off — offer to open settings. */
@Composable
fun NfcDisabledScreen(onOpenSettings: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("NFC is off", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp))
Text(
"Turn on NFC to read a card.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
Button(onClick = onOpenSettings) { Text("Open NFC settings") }
}
}
/** Waiting for a tap (or actively reading). */
@Composable
fun WaitingScreen(state: TapState, label: String, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
NfcCardAnimation(state = state, label = label, modifier = Modifier.fillMaxWidth())
}
}
/** Full dump of a card read: a success/error animation, formatted fields, and raw data. */
@Composable
fun ResultScreen(
result: EmvCardReader.Result,
onReadAnother: () -> Unit,
modifier: Modifier = Modifier,
) {
var tab by remember { mutableIntStateOf(0) }
Column(modifier = modifier.fillMaxSize()) {
NfcCardAnimation(
state = if (result.hasData) TapState.SUCCESS else TapState.ERROR,
label = if (result.hasData) "Card read" else "Couldn't read card",
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
if (result.error != null && !result.hasData) {
Text(
result.error,
modifier = Modifier.fillMaxWidth().padding(24.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.error,
)
}
SecondaryTabRow(selectedTabIndex = tab) {
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Formatted") })
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Raw dump") })
}
Column(
Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
if (tab == 0) FormattedView(result) else RawDumpView(result)
Spacer(Modifier.height(16.dp))
Button(onClick = onReadAnother, modifier = Modifier.fillMaxWidth()) { Text("Read another card") }
Spacer(Modifier.height(32.dp))
}
}
}
@Composable
private fun FormattedView(result: EmvCardReader.Result) {
if (result.fields.isNotEmpty()) {
SectionCard("Card data") {
result.fields.forEachIndexed { i, f ->
if (i > 0) HorizontalDivider(Modifier.padding(vertical = 8.dp))
FieldRow(f.name, f.value)
}
}
Spacer(Modifier.height(12.dp))
}
result.apps.forEach { app ->
SectionCard("Application ${app.label ?: app.aid}") {
FieldRow("AID", app.aid)
app.label?.let { FieldRow("Label", it) }
app.preferredName?.let { FieldRow("Preferred name", it) }
}
Spacer(Modifier.height(12.dp))
}
SectionCard("Tag / NFC") {
FieldRow("UID", result.uid)
FieldRow("Technologies", result.techList.joinToString(", ") { it.substringAfterLast('.') })
result.atqa?.let { FieldRow("ATQA", it) }
result.sak?.let { FieldRow("SAK", it) }
result.historicalBytes?.let { FieldRow("Historical bytes", it) }
result.hiLayerResponse?.let { FieldRow("Higher-layer response", it) }
}
}
@Composable
private fun RawDumpView(result: EmvCardReader.Result) {
result.apps.forEach { app ->
SectionCard("TLV tree — ${app.label ?: app.aid}") {
app.tlv.forEach { TlvRow(it, 0) }
}
Spacer(Modifier.height(12.dp))
}
SectionCard("APDU log (${result.apduLog.size})") {
result.apduLog.forEachIndexed { i, a ->
if (i > 0) HorizontalDivider(Modifier.padding(vertical = 10.dp))
Text(
a.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(4.dp))
MonoLabeled(">>", Hex.pretty(a.command), MaterialTheme.colorScheme.primary)
MonoLabeled("<<", Hex.pretty(a.response), MaterialTheme.colorScheme.onSurface)
Text(
"SW ${a.sw} — ${a.swMeaning}",
fontSize = 12.sp,
color = if (a.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
@Composable
private fun TlvRow(node: TlvNode, depth: Int) {
Column(Modifier.padding(start = (depth * 12).dp, top = 4.dp, bottom = 4.dp)) {
Row {
Text(
node.tag,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(8.dp))
Text(node.name, fontSize = 13.sp, fontWeight = FontWeight.Medium)
}
if (node.constructed) {
node.children.forEach { TlvRow(it, depth + 1) }
} else {
MonoLabeled("", node.valueHex, MaterialTheme.colorScheme.onSurfaceVariant)
node.interpreted?.let {
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 1.dp))
}
}
}
}
@Composable
private fun MonoLabeled(prefix: String, text: String, color: androidx.compose.ui.graphics.Color) {
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) {
if (prefix.isNotEmpty()) {
Text(prefix, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = color)
Spacer(Modifier.width(6.dp))
}
Text(text.ifBlank { "(empty)" }, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = color)
}
}
@Composable
private fun FieldRow(name: String, value: String) {
Column(Modifier.fillMaxWidth()) {
Text(name, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, fontSize = 15.sp, fontWeight = FontWeight.Medium)
}
}
@Composable
private fun SectionCard(title: String, content: @Composable () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)),
) {
Column(Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp))
content()
}
}
}
@Composable
private fun CenteredMessage(title: String, body: String, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
Spacer(Modifier.height(12.dp))
Text(body, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@@ -1,170 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Solid brand-coloured background for the adaptive icon. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:fillColor="#6650A4"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -1,30 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- NFC "tap phone" glyph (from the android app's ic_nfc), scaled into the adaptive-icon safe
zone. White so it reads on the coloured background; also reused as the monochrome layer. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
<group
android:scaleX="1.5"
android:scaleY="1.5"
android:translateX="18"
android:translateY="18">
<!-- Phone outline -->
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M6.08,8.6 L20.45,8.6 A1.58,1.58,0,0,1,22.03,10.18 L22.03,37.81 A1.58,1.58,0,0,1,20.45,39.39 L6.08,39.39 A1.58,1.58,0,0,1,4.5,37.81 L4.5,10.18 A1.58,1.58,0,0,1,6.08,8.6 Z" />
<!-- Top notch line -->
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:pathData="M4.5,12.55 L22.03,12.55" />
<!-- Bottom notch line -->
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:pathData="M4.5,35.45 L22.03,35.45" />
<!-- NFC waves (outer, mid, inner) -->
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M36.07,9.29 a18.28,18.28,0,0,1,0,29.42 m-4,-24.16 a11.84,11.84,0,0,1,5,9.45 11.84,11.84,0,0,1,-5,9.45 m-3.68,-14 a5.67,5.67,0,0,1,0,9.1" />
</group>
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Pre-API-26 fallback: composes the same background + NFC foreground as a flat icon. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_background" />
<item android:drawable="@drawable/ic_launcher_foreground" />
</layer-list>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Pre-API-26 round fallback: same composition; the launcher masks it to a circle. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_background" />
<item android:drawable="@drawable/ic_launcher_foreground" />
</layer-list>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dark window background, so there's no white flash before Compose draws in dark mode. -->
<color name="windowBackground">#FF1C1B1F</color>
</resources>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dark override, applied automatically when the system is in dark mode. Same keys as the
light theme; windowBackground resolves to the dark value in values-night/colors.xml. -->
<style name="Theme.Sw9000" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/windowBackground</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>
+2
View File
@@ -7,4 +7,6 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- Window background shown before Compose draws. Light value; see values-night for dark. -->
<color name="windowBackground">#FFFFFBFE</color>
</resources>
+3 -1
View File
@@ -1,3 +1,5 @@
<resources>
<string name="app_name">sw9000</string>
</resources>
<string name="tap_card">Tap a card to read it</string>
<string name="reading">Reading card… hold still</string>
</resources>
+8 -2
View File
@@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Sw9000" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
<!-- Light base. Compose themes the content; this just sets the pre-draw window so the splash
matches the system theme. The dark override lives in values-night/themes.xml. -->
<style name="Theme.Sw9000" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/windowBackground</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>