@@ -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
|
# 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 {
|
android {
|
||||||
namespace = "sh.sar.sw9000"
|
namespace = "sh.sar.sw9000"
|
||||||
compileSdk {
|
compileSdk {
|
||||||
version = release(36) {
|
version = release(37)
|
||||||
minorApiLevel = 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
@@ -21,8 +19,21 @@ android {
|
|||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
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
|
package sh.sar.sw9000
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.nfc.NfcAdapter
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
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.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.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() {
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
Sw9000Theme {
|
Sw9000Theme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
Scaffold(
|
||||||
Greeting(
|
modifier = Modifier.fillMaxSize(),
|
||||||
name = "Android",
|
topBar = { TopAppBar(title = { Text(getString(R.string.app_name)) }) },
|
||||||
modifier = Modifier.padding(innerPadding)
|
) { 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
|
override fun onResume() {
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
super.onResume()
|
||||||
Text(
|
syncAdapterState()
|
||||||
text = "Hello $name!",
|
}
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
override fun onPause() {
|
||||||
@Composable
|
super.onPause()
|
||||||
fun GreetingPreview() {
|
runCatching { adapter?.disableReaderMode(this) }
|
||||||
Sw9000Theme {
|
}
|
||||||
Greeting("Android")
|
|
||||||
|
/** 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"?>
|
<?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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#3DDC84"
|
android:fillColor="#6650A4"
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
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>
|
</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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="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">
|
<group
|
||||||
<gradient
|
android:scaleX="1.5"
|
||||||
android:endX="85.84757"
|
android:scaleY="1.5"
|
||||||
android:endY="92.4963"
|
android:translateX="18"
|
||||||
android:startX="42.9492"
|
android:translateY="18">
|
||||||
android:startY="49.59793"
|
|
||||||
android:type="linear">
|
<!-- Phone outline -->
|
||||||
<item
|
<path
|
||||||
android:color="#44000000"
|
android:fillColor="#00000000"
|
||||||
android:offset="0.0" />
|
android:strokeColor="#FFFFFFFF"
|
||||||
<item
|
android:strokeWidth="2"
|
||||||
android:color="#00000000"
|
android:strokeLineCap="round"
|
||||||
android:offset="1.0" />
|
android:strokeLineJoin="round"
|
||||||
</gradient>
|
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" />
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
<!-- Top notch line -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#00000000"
|
||||||
android:fillType="nonZero"
|
android:strokeColor="#FFFFFFFF"
|
||||||
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="2"
|
||||||
android:strokeWidth="1"
|
android:strokeLineCap="round"
|
||||||
android:strokeColor="#00000000" />
|
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>
|
</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="teal_700">#FF018786</color>
|
||||||
<color name="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</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>
|
</resources>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">sw9000</string>
|
<string name="app_name">sw9000</string>
|
||||||
|
<string name="tap_card">Tap a card to read it</string>
|
||||||
|
<string name="reading">Reading card… hold still</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<style name="Theme.Sw9000" parent="android:Theme.Material.Light.NoActionBar" />
|
<!-- 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>
|
</resources>
|
||||||