@@ -0,0 +1,3 @@
|
||||
KEYSTORE_PASSWORD=your_keystore_password_here
|
||||
KEY_ALIAS=your_key_alias_here
|
||||
KEY_PASSWORD=your_key_password_here
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
release/
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
/build
|
||||
/build
|
||||
# Signing keystore written by CI — never commit it.
|
||||
key.jks
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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",
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||