Initial commit: JRZ NFC Cloner
Android NFC app with read, write, clone and HCE emulation. Supports NDEF, NFC-V (ISO 15693), MIFARE Ultralight and Classic. Dark cyan Material3 theme with edge-to-edge layout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -0,0 +1,19 @@
|
|||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
**/build/
|
||||||
|
|
||||||
|
# Android Studio
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
local.properties
|
||||||
|
*.DS_Store
|
||||||
|
|
||||||
|
# APK / Signing
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
|
||||||
|
# Generated
|
||||||
|
app/src/main/assets/
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.jrz.nfcapp"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.jrz.nfcapp"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.appcompat)
|
||||||
|
implementation(libs.material)
|
||||||
|
implementation(libs.androidx.constraintlayout)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
|
implementation(libs.androidx.navigation.fragment.ktx)
|
||||||
|
implementation(libs.androidx.navigation.ui.ktx)
|
||||||
|
implementation(libs.androidx.viewpager2)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.NFC" />
|
||||||
|
<uses-feature android:name="android.hardware.nfc" android:required="true" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.NFCApp">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ui.MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- NFC Tag Discovery -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="*/*" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.nfc.action.TECH_DISCOVERED" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.nfc.action.TAG_DISCOVERED" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.nfc.action.TECH_DISCOVERED"
|
||||||
|
android:resource="@xml/nfc_tech_filter" />
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- HCE Service -->
|
||||||
|
<service
|
||||||
|
android:name=".service.HceService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_NFC_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.nfc.cardemulation.host_apdu_service"
|
||||||
|
android:resource="@xml/apduservice" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.jrz.nfcapp.model
|
||||||
|
|
||||||
|
import android.nfc.NdefMessage
|
||||||
|
|
||||||
|
data class TagDump(
|
||||||
|
val uid: String,
|
||||||
|
val tagType: String,
|
||||||
|
val techList: List<String>,
|
||||||
|
val ndefMessage: NdefMessage?,
|
||||||
|
val ndefCapacity: Int,
|
||||||
|
val ndefMaxSize: Int,
|
||||||
|
val isNdefWritable: Boolean,
|
||||||
|
val rawPages: List<ByteArray>,
|
||||||
|
val rawBytes: ByteArray,
|
||||||
|
val debugLog: String = ""
|
||||||
|
) {
|
||||||
|
fun formattedUid(): String = uid.uppercase()
|
||||||
|
|
||||||
|
fun shortTechList(): String = techList
|
||||||
|
.map { it.substringAfterLast('.') }
|
||||||
|
.joinToString(", ")
|
||||||
|
|
||||||
|
fun ndefContentSummary(): String {
|
||||||
|
val msg = ndefMessage ?: return "Kein NDEF"
|
||||||
|
return buildString {
|
||||||
|
msg.records.forEachIndexed { i, record ->
|
||||||
|
append("Record $i: TNF=${record.tnf} ")
|
||||||
|
val type = String(record.type)
|
||||||
|
append("Type=$type ")
|
||||||
|
val payload = record.payload
|
||||||
|
when {
|
||||||
|
type == "T" -> {
|
||||||
|
// NDEF Text record
|
||||||
|
val langLen = payload[0].toInt() and 0x3F
|
||||||
|
append("→ \"${String(payload, langLen + 1, payload.size - langLen - 1)}\"\n")
|
||||||
|
}
|
||||||
|
type == "U" -> {
|
||||||
|
// NDEF URI record
|
||||||
|
val prefix = URI_PREFIXES.getOrElse(payload[0].toInt() and 0xFF) { "" }
|
||||||
|
append("→ $prefix${String(payload, 1, payload.size - 1)}\n")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
append("→ ${payload.toHex()}\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.trimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rawPagesFormatted(): String = buildString {
|
||||||
|
rawPages.forEachIndexed { i, page ->
|
||||||
|
append("Blk %03d: ".format(i))
|
||||||
|
append(page.toHex())
|
||||||
|
append(" ")
|
||||||
|
page.forEach { b ->
|
||||||
|
val c = (b.toInt() and 0xFF).toChar()
|
||||||
|
append(if (c.code in 32..126) c else '.')
|
||||||
|
}
|
||||||
|
append('\n')
|
||||||
|
}
|
||||||
|
}.trimEnd()
|
||||||
|
|
||||||
|
fun toCsvDump(): String = buildString {
|
||||||
|
appendLine("# NFC Tag Dump")
|
||||||
|
appendLine("UID,$uid")
|
||||||
|
appendLine("Type,$tagType")
|
||||||
|
appendLine("Technologies,${techList.joinToString("|")}")
|
||||||
|
appendLine("NDEF_Capacity,$ndefCapacity")
|
||||||
|
appendLine("NDEF_MaxSize,$ndefMaxSize")
|
||||||
|
appendLine("NDEF_Writable,$isNdefWritable")
|
||||||
|
appendLine()
|
||||||
|
appendLine("# Raw Pages")
|
||||||
|
rawPages.forEachIndexed { i, page ->
|
||||||
|
appendLine("Page_$i,${page.toHex()}")
|
||||||
|
}
|
||||||
|
if (ndefMessage != null) {
|
||||||
|
appendLine()
|
||||||
|
appendLine("# NDEF Records")
|
||||||
|
ndefMessage.records.forEachIndexed { i, rec ->
|
||||||
|
appendLine("Record_${i}_TNF,${rec.tnf}")
|
||||||
|
appendLine("Record_${i}_Type,${rec.type.toHex()}")
|
||||||
|
appendLine("Record_${i}_Payload,${rec.payload.toHex()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val URI_PREFIXES = mapOf(
|
||||||
|
0x00 to "", 0x01 to "http://www.", 0x02 to "https://www.",
|
||||||
|
0x03 to "http://", 0x04 to "https://", 0x05 to "tel:",
|
||||||
|
0x06 to "mailto:", 0x07 to "ftp://anonymous:anonymous@",
|
||||||
|
0x08 to "ftp://ftp.", 0x09 to "ftps://", 0x0A to "sftp://",
|
||||||
|
0x0B to "smb://", 0x0C to "nfs://", 0x0D to "ftp://",
|
||||||
|
0x0E to "dav://", 0x0F to "news:", 0x10 to "telnet://",
|
||||||
|
0x11 to "imap:", 0x12 to "rtsp://", 0x13 to "urn:",
|
||||||
|
0x14 to "pop:", 0x15 to "sip:", 0x16 to "sips:",
|
||||||
|
0x17 to "tftp:", 0x18 to "btspp://", 0x19 to "btl2cap://",
|
||||||
|
0x1A to "btgoep://", 0x1B to "tcpobex://", 0x1C to "irdaobex://",
|
||||||
|
0x1D to "file://", 0x1E to "urn:epc:id:", 0x1F to "urn:epc:tag:",
|
||||||
|
0x20 to "urn:epc:pat:", 0x21 to "urn:epc:raw:", 0x22 to "urn:epc:",
|
||||||
|
0x23 to "urn:nfc:"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteArray.toHex(): String = joinToString(" ") { "%02X".format(it.toInt() and 0xFF) }
|
||||||
|
fun String.hexToBytes(): ByteArray {
|
||||||
|
val s = replace(" ", "").replace(":", "")
|
||||||
|
return ByteArray(s.length / 2) { i -> s.substring(i * 2, i * 2 + 2).toInt(16).toByte() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package com.jrz.nfcapp.service
|
||||||
|
|
||||||
|
import android.nfc.cardemulation.HostApduService
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import com.jrz.nfcapp.model.toHex
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HCE-Dienst für NDEF-Tag-Emulation.
|
||||||
|
*
|
||||||
|
* Implementiert das NFC Forum Type 4 Tag Protokoll vereinfacht:
|
||||||
|
* 1. SELECT Application (AID)
|
||||||
|
* 2. SELECT Capability Container (CC)
|
||||||
|
* 3. READ BINARY CC
|
||||||
|
* 4. SELECT NDEF File
|
||||||
|
* 5. READ BINARY NDEF
|
||||||
|
*
|
||||||
|
* Nur geeignet für Systeme, die AID/APDU-basiert kommunizieren.
|
||||||
|
* UID-basierte Systeme (Zugangskontrolle) werden NICHT unterstützt.
|
||||||
|
*/
|
||||||
|
class HceService : HostApduService() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HceService"
|
||||||
|
|
||||||
|
// Status words
|
||||||
|
private val SW_OK = byteArrayOf(0x90.toByte(), 0x00)
|
||||||
|
private val SW_NOT_FOUND = byteArrayOf(0x6A.toByte(), 0x82.toByte())
|
||||||
|
private val SW_WRONG_LENGTH = byteArrayOf(0x67.toByte(), 0x00)
|
||||||
|
private val SW_UNKNOWN = byteArrayOf(0x6F.toByte(), 0x00)
|
||||||
|
|
||||||
|
// File IDs
|
||||||
|
private val CC_FILE_ID = byteArrayOf(0xE1.toByte(), 0x03)
|
||||||
|
private val NDEF_FILE_ID = byteArrayOf(0xE1.toByte(), 0x04)
|
||||||
|
|
||||||
|
// Selected file state
|
||||||
|
private const val SEL_NONE = 0
|
||||||
|
private const val SEL_CC = 1
|
||||||
|
private const val SEL_NDEF = 2
|
||||||
|
|
||||||
|
// Singleton payload + log callback (set from HceFragment)
|
||||||
|
@Volatile var ndefPayload: ByteArray = buildDefaultNdefPayload()
|
||||||
|
var apduLogCallback: ((String) -> Unit)? = null
|
||||||
|
|
||||||
|
fun buildDefaultNdefPayload(): ByteArray {
|
||||||
|
// NDEF Text record: "NFC Tool"
|
||||||
|
val text = "NFC Tool"
|
||||||
|
val lang = "de"
|
||||||
|
val langBytes = lang.toByteArray()
|
||||||
|
val textBytes = text.toByteArray()
|
||||||
|
val recordPayload = ByteArray(1 + langBytes.size + textBytes.size)
|
||||||
|
recordPayload[0] = langBytes.size.toByte()
|
||||||
|
langBytes.copyInto(recordPayload, 1)
|
||||||
|
textBytes.copyInto(recordPayload, 1 + langBytes.size)
|
||||||
|
|
||||||
|
// NDEF record header: MB=1 ME=1 SR=1 TNF=1 (Well-Known)
|
||||||
|
val typeBytes = byteArrayOf(0x54) // 'T'
|
||||||
|
val recordHeader = byteArrayOf(
|
||||||
|
0xD1.toByte(), // MB=1 ME=1 SR=1 TNF=0x01
|
||||||
|
typeBytes.size.toByte(), // type length
|
||||||
|
recordPayload.size.toByte() // short record payload length
|
||||||
|
)
|
||||||
|
val ndefRecord = recordHeader + typeBytes + recordPayload
|
||||||
|
|
||||||
|
// NDEF file: 2-byte length + record
|
||||||
|
return byteArrayOf(
|
||||||
|
0x00, ndefRecord.size.toByte()
|
||||||
|
) + ndefRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedFile = SEL_NONE
|
||||||
|
|
||||||
|
override fun processCommandApdu(commandApdu: ByteArray, extras: Bundle?): ByteArray {
|
||||||
|
val hex = commandApdu.toHex()
|
||||||
|
log("CMD: $hex")
|
||||||
|
|
||||||
|
if (commandApdu.size < 4) {
|
||||||
|
log("RSP: Wrong length")
|
||||||
|
return SW_WRONG_LENGTH
|
||||||
|
}
|
||||||
|
|
||||||
|
val cla = commandApdu[0].toInt() and 0xFF
|
||||||
|
val ins = commandApdu[1].toInt() and 0xFF
|
||||||
|
val p1 = commandApdu[2].toInt() and 0xFF
|
||||||
|
val p2 = commandApdu[3].toInt() and 0xFF
|
||||||
|
|
||||||
|
return when {
|
||||||
|
ins == 0xA4 -> handleSelect(commandApdu, p1, p2)
|
||||||
|
ins == 0xB0 -> handleReadBinary(commandApdu, p1, p2)
|
||||||
|
else -> {
|
||||||
|
log("RSP: Unknown INS ${"%02X".format(ins)}")
|
||||||
|
SW_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSelect(apdu: ByteArray, p1: Int, p2: Int): ByteArray {
|
||||||
|
return when {
|
||||||
|
// SELECT Application by AID (P1=0x04)
|
||||||
|
p1 == 0x04 -> {
|
||||||
|
log("RSP: SELECT Application -> OK")
|
||||||
|
selectedFile = SEL_NONE
|
||||||
|
SW_OK
|
||||||
|
}
|
||||||
|
// SELECT File by ID (P1=0x00 or 0x02)
|
||||||
|
p1 == 0x00 || p1 == 0x02 -> {
|
||||||
|
if (apdu.size < 7) return SW_WRONG_LENGTH
|
||||||
|
val fileId = byteArrayOf(apdu[5], apdu[6])
|
||||||
|
when {
|
||||||
|
fileId.contentEquals(CC_FILE_ID) -> {
|
||||||
|
selectedFile = SEL_CC
|
||||||
|
log("RSP: SELECT CC -> OK")
|
||||||
|
SW_OK
|
||||||
|
}
|
||||||
|
fileId.contentEquals(NDEF_FILE_ID) -> {
|
||||||
|
selectedFile = SEL_NDEF
|
||||||
|
log("RSP: SELECT NDEF -> OK")
|
||||||
|
SW_OK
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
log("RSP: SELECT unknown file ${fileId.toHex()} -> NOT FOUND")
|
||||||
|
SW_NOT_FOUND
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
log("RSP: SELECT unknown P1=${"%02X".format(p1)} -> NOT FOUND")
|
||||||
|
SW_NOT_FOUND
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleReadBinary(apdu: ByteArray, p1: Int, p2: Int): ByteArray {
|
||||||
|
val offset = (p1 shl 8) or p2
|
||||||
|
val length = if (apdu.size >= 5) apdu[4].toInt() and 0xFF else 15
|
||||||
|
|
||||||
|
return when (selectedFile) {
|
||||||
|
SEL_CC -> {
|
||||||
|
val cc = buildCapabilityContainer()
|
||||||
|
val end = minOf(offset + length, cc.size)
|
||||||
|
if (offset >= cc.size) return SW_WRONG_LENGTH
|
||||||
|
val data = cc.copyOfRange(offset, end)
|
||||||
|
log("RSP: READ CC offset=$offset len=$length -> ${data.toHex()} 90 00")
|
||||||
|
data + SW_OK
|
||||||
|
}
|
||||||
|
SEL_NDEF -> {
|
||||||
|
val ndef = ndefPayload
|
||||||
|
val end = minOf(offset + length, ndef.size)
|
||||||
|
if (offset >= ndef.size) return SW_WRONG_LENGTH
|
||||||
|
val data = ndef.copyOfRange(offset, end)
|
||||||
|
log("RSP: READ NDEF offset=$offset len=$length -> ${data.toHex()} 90 00")
|
||||||
|
data + SW_OK
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
log("RSP: READ without SELECT -> NOT FOUND")
|
||||||
|
SW_NOT_FOUND
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCapabilityContainer(): ByteArray {
|
||||||
|
val ndefSize = ndefPayload.size
|
||||||
|
return byteArrayOf(
|
||||||
|
0x00, 0x0F, // CC length = 15 bytes
|
||||||
|
0x20, // Mapping version 2.0
|
||||||
|
0x00, 0x3B, // MLe (max R-APDU): 59 bytes
|
||||||
|
0x00, 0x34, // MLc (max C-APDU): 52 bytes
|
||||||
|
0x04, // NDEF File Control TLV tag
|
||||||
|
0x06, // TLV length
|
||||||
|
NDEF_FILE_ID[0], NDEF_FILE_ID[1], // File ID
|
||||||
|
0x00, ndefSize.toByte(),// Max NDEF size
|
||||||
|
0x00, // Read access: open
|
||||||
|
0xFF.toByte() // Write access: no write (read-only HCE)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun log(msg: String) {
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
apduLogCallback?.invoke(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDeactivated(reason: Int) {
|
||||||
|
val why = if (reason == DEACTIVATION_LINK_LOSS) "Link-Verlust" else "Neues Tag"
|
||||||
|
log("--- Deaktiviert: $why ---")
|
||||||
|
selectedFile = SEL_NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.jrz.nfcapp.ui
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.nfc.NfcAdapter
|
||||||
|
import android.nfc.Tag
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
|
import com.jrz.nfcapp.R
|
||||||
|
import com.jrz.nfcapp.databinding.ActivityMainBinding
|
||||||
|
import com.jrz.nfcapp.ui.clone.CloneFragment
|
||||||
|
import com.jrz.nfcapp.ui.hce.HceFragment
|
||||||
|
import com.jrz.nfcapp.ui.read.ReadFragment
|
||||||
|
import com.jrz.nfcapp.ui.write.WriteFragment
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var binding: ActivityMainBinding
|
||||||
|
private var nfcAdapter: NfcAdapter? = null
|
||||||
|
private var pendingIntent: PendingIntent? = null
|
||||||
|
|
||||||
|
private lateinit var readFragment: ReadFragment
|
||||||
|
private lateinit var writeFragment: WriteFragment
|
||||||
|
private lateinit var cloneFragment: CloneFragment
|
||||||
|
private lateinit var hceFragment: HceFragment
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// Edge-to-edge: content draws behind status & nav bar
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
// Apply insets: AppBarLayout handles top via fitsSystemWindows,
|
||||||
|
// ViewPager gets bottom padding for the navigation bar
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.viewPager) { view, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
view.updatePadding(bottom = bars.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
|
||||||
|
if (nfcAdapter == null) {
|
||||||
|
Toast.makeText(this, getString(R.string.nfc_not_supported), Toast.LENGTH_LONG).show()
|
||||||
|
updateNfcBadge(supported = false)
|
||||||
|
} else {
|
||||||
|
updateNfcBadge(supported = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0, intent,
|
||||||
|
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
setupViewPager()
|
||||||
|
|
||||||
|
if (intent.hasExtra(NfcAdapter.EXTRA_TAG)) {
|
||||||
|
onNewIntent(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNfcBadge(supported: Boolean) {
|
||||||
|
if (!supported) {
|
||||||
|
binding.tvNfcStatus.text = "KEIN NFC"
|
||||||
|
binding.nfcStatusDot.setBackgroundResource(R.drawable.dot_inactive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupViewPager() {
|
||||||
|
readFragment = ReadFragment()
|
||||||
|
writeFragment = WriteFragment()
|
||||||
|
cloneFragment = CloneFragment()
|
||||||
|
hceFragment = HceFragment()
|
||||||
|
|
||||||
|
val fragments = listOf(readFragment, writeFragment, cloneFragment, hceFragment)
|
||||||
|
val titles = listOf(
|
||||||
|
getString(R.string.tab_read),
|
||||||
|
getString(R.string.tab_write),
|
||||||
|
getString(R.string.tab_clone),
|
||||||
|
getString(R.string.tab_hce)
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.viewPager.adapter = object : FragmentStateAdapter(this) {
|
||||||
|
override fun getItemCount() = fragments.size
|
||||||
|
override fun createFragment(position: Int) = fragments[position]
|
||||||
|
}
|
||||||
|
|
||||||
|
TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
|
||||||
|
tab.text = titles[position]
|
||||||
|
}.attach()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
val adapter = nfcAdapter ?: return
|
||||||
|
if (!adapter.isEnabled) {
|
||||||
|
Toast.makeText(this, getString(R.string.nfc_disabled), Toast.LENGTH_LONG).show()
|
||||||
|
binding.tvNfcStatus.text = "AUS"
|
||||||
|
binding.nfcStatusDot.setBackgroundResource(R.drawable.dot_inactive)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tvNfcStatus.text = "NFC"
|
||||||
|
binding.nfcStatusDot.setBackgroundResource(R.drawable.dot_active)
|
||||||
|
adapter.enableForegroundDispatch(this, pendingIntent, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
nfcAdapter?.disableForegroundDispatch(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
val tag = intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) ?: return
|
||||||
|
when (binding.viewPager.currentItem) {
|
||||||
|
0 -> readFragment.onTagScanned(tag)
|
||||||
|
1 -> writeFragment.onTagScanned(tag)
|
||||||
|
2 -> cloneFragment.onTagScanned(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.jrz.nfcapp.ui.clone
|
||||||
|
|
||||||
|
import android.nfc.Tag
|
||||||
|
import java.io.IOException
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.jrz.nfcapp.R
|
||||||
|
import com.jrz.nfcapp.databinding.FragmentCloneBinding
|
||||||
|
import com.jrz.nfcapp.model.TagDump
|
||||||
|
import com.jrz.nfcapp.util.NfcHelper
|
||||||
|
|
||||||
|
enum class CloneState { IDLE, READING_SOURCE, WRITING_TARGET }
|
||||||
|
|
||||||
|
class CloneFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentCloneBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
var state: CloneState = CloneState.IDLE
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var sourceDump: TagDump? = null
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentCloneBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
binding.btnReadSource.setOnClickListener {
|
||||||
|
state = CloneState.READING_SOURCE
|
||||||
|
binding.tvSourceStatus.text = getString(R.string.clone_waiting_source)
|
||||||
|
binding.btnReadSource.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnWriteTarget.setOnClickListener {
|
||||||
|
if (sourceDump == null) return@setOnClickListener
|
||||||
|
state = CloneState.WRITING_TARGET
|
||||||
|
binding.tvTargetStatus.text = getString(R.string.clone_waiting_target)
|
||||||
|
binding.btnWriteTarget.isEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTagScanned(tag: Tag) {
|
||||||
|
when (state) {
|
||||||
|
CloneState.READING_SOURCE -> readSource(tag)
|
||||||
|
CloneState.WRITING_TARGET -> writeTarget(tag)
|
||||||
|
CloneState.IDLE -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readSource(tag: Tag) {
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val dump = NfcHelper.readTag(tag)
|
||||||
|
sourceDump = dump
|
||||||
|
requireActivity().runOnUiThread { onSourceRead(dump) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
requireActivity().runOnUiThread {
|
||||||
|
state = CloneState.IDLE
|
||||||
|
binding.tvSourceStatus.text = getString(R.string.error_read, e.message)
|
||||||
|
binding.btnReadSource.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSourceRead(dump: TagDump) {
|
||||||
|
state = CloneState.IDLE
|
||||||
|
binding.btnReadSource.isEnabled = true
|
||||||
|
binding.tvSourceStatus.text = getString(R.string.clone_source_ready, dump.rawBytes.size)
|
||||||
|
binding.layoutSourceDetails.visibility = View.VISIBLE
|
||||||
|
binding.tvSourceUid.text = "UID: ${dump.formattedUid()}"
|
||||||
|
binding.tvSourceType.text = "Typ: ${dump.tagType}"
|
||||||
|
val sizeInfo = buildString {
|
||||||
|
if (dump.ndefMessage != null) append("NDEF: ${dump.ndefMessage.byteArrayLength} Bytes ")
|
||||||
|
if (dump.rawPages.isNotEmpty()) append("Pages: ${dump.rawPages.size} (${dump.rawBytes.size} Bytes)")
|
||||||
|
if (dump.ndefMessage == null && dump.rawPages.isEmpty()) append("Keine lesbaren Daten")
|
||||||
|
}
|
||||||
|
binding.tvSourceSize.text = sizeInfo
|
||||||
|
binding.btnWriteTarget.isEnabled = dump.ndefMessage != null || dump.rawPages.isNotEmpty()
|
||||||
|
binding.tvTargetStatus.text = if (binding.btnWriteTarget.isEnabled)
|
||||||
|
"Bereit zum Schreiben" else "Keine schreibbaren Daten"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeTarget(tag: Tag) {
|
||||||
|
val dump = sourceDump ?: return
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
when (NfcHelper.detectWriteMethod(tag)) {
|
||||||
|
NfcHelper.WriteMethod.NFC_V -> {
|
||||||
|
if (dump.rawPages.isEmpty()) throw IOException("Keine Quelldaten zum Schreiben")
|
||||||
|
NfcHelper.writeNfcV(tag, dump.rawPages)
|
||||||
|
}
|
||||||
|
NfcHelper.WriteMethod.ULTRALIGHT -> {
|
||||||
|
if (dump.rawPages.isEmpty()) throw IOException("Keine Quelldaten zum Schreiben")
|
||||||
|
NfcHelper.writeUltralight(tag, dump.rawPages)
|
||||||
|
}
|
||||||
|
NfcHelper.WriteMethod.NDEF,
|
||||||
|
NfcHelper.WriteMethod.NDEF_FORMATABLE -> {
|
||||||
|
val msg = dump.ndefMessage ?: throw IOException("Keine NDEF-Daten in Quelle")
|
||||||
|
NfcHelper.writeNdef(tag, msg)
|
||||||
|
}
|
||||||
|
NfcHelper.WriteMethod.MIFARE_CLASSIC ->
|
||||||
|
throw IOException("MIFARE Classic Schreiben benötigt Schlüssel")
|
||||||
|
NfcHelper.WriteMethod.UNSUPPORTED ->
|
||||||
|
throw IOException("Ziel-Tag unterstützt kein Schreiben")
|
||||||
|
}
|
||||||
|
requireActivity().runOnUiThread { onWriteSuccess() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
requireActivity().runOnUiThread { onWriteError(e.message ?: "Fehler") }
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onWriteSuccess() {
|
||||||
|
state = CloneState.IDLE
|
||||||
|
binding.tvTargetStatus.text = getString(R.string.clone_success)
|
||||||
|
binding.btnWriteTarget.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onWriteError(error: String) {
|
||||||
|
state = CloneState.IDLE
|
||||||
|
binding.tvTargetStatus.text = getString(R.string.error_write, error)
|
||||||
|
binding.btnWriteTarget.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension for display
|
||||||
|
private fun TagDump.formattedUid() = uid.uppercase()
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package com.jrz.nfcapp.ui.hce
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Intent
|
||||||
|
import android.nfc.NdefMessage
|
||||||
|
import android.nfc.NdefRecord
|
||||||
|
import android.nfc.cardemulation.CardEmulation
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import com.jrz.nfcapp.R
|
||||||
|
import com.jrz.nfcapp.databinding.FragmentHceBinding
|
||||||
|
import com.jrz.nfcapp.service.HceService
|
||||||
|
|
||||||
|
class HceFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentHceBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private var isActive = false
|
||||||
|
private val apduLogLines = ArrayDeque<String>(200)
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentHceBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
binding.btnEnableHce.setOnClickListener { enableHce() }
|
||||||
|
binding.btnDisableHce.setOnClickListener { disableHce() }
|
||||||
|
binding.btnClearLog.setOnClickListener {
|
||||||
|
apduLogLines.clear()
|
||||||
|
binding.tvApduLog.text = "Kein APDU-Verkehr…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register log callback
|
||||||
|
HceService.apduLogCallback = { line ->
|
||||||
|
requireActivity().runOnUiThread { appendLog(line) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableHce() {
|
||||||
|
val aid = binding.etAid.text?.toString()?.trim()?.replace(" ", "")?.uppercase() ?: ""
|
||||||
|
val payloadText = binding.etPayload.text?.toString()?.trim() ?: ""
|
||||||
|
|
||||||
|
if (aid.isEmpty()) {
|
||||||
|
Snackbar.make(binding.root, "Bitte AID eingeben", Snackbar.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (aid.length % 2 != 0 || aid.length < 10 || aid.length > 32) {
|
||||||
|
Snackbar.make(binding.root, "AID muss 5-16 Hex-Bytes sein", Snackbar.LENGTH_LONG).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build NDEF payload
|
||||||
|
HceService.ndefPayload = buildNdefPayload(payloadText)
|
||||||
|
|
||||||
|
isActive = true
|
||||||
|
updateUi()
|
||||||
|
appendLog("=== HCE aktiviert, AID: $aid ===")
|
||||||
|
Snackbar.make(binding.root, "HCE aktiv. Halte das Telefon an ein Lesegerät.", Snackbar.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableHce() {
|
||||||
|
isActive = false
|
||||||
|
updateUi()
|
||||||
|
appendLog("=== HCE deaktiviert ===")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateUi() {
|
||||||
|
binding.hceStatusDot.setBackgroundResource(
|
||||||
|
if (isActive) R.drawable.dot_active else R.drawable.dot_inactive
|
||||||
|
)
|
||||||
|
binding.tvHceStatus.text = getString(
|
||||||
|
if (isActive) R.string.hce_status_active else R.string.hce_status_inactive
|
||||||
|
)
|
||||||
|
binding.btnEnableHce.isEnabled = !isActive
|
||||||
|
binding.btnDisableHce.isEnabled = isActive
|
||||||
|
binding.etAid.isEnabled = !isActive
|
||||||
|
binding.etPayload.isEnabled = !isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendLog(line: String) {
|
||||||
|
apduLogLines.addLast(line)
|
||||||
|
if (apduLogLines.size > 100) apduLogLines.removeFirst()
|
||||||
|
binding.tvApduLog.text = apduLogLines.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNdefPayload(text: String): ByteArray {
|
||||||
|
if (text.isEmpty()) return HceService.buildDefaultNdefPayload()
|
||||||
|
|
||||||
|
val lang = "de"
|
||||||
|
val langBytes = lang.toByteArray()
|
||||||
|
val textBytes = text.toByteArray(Charsets.UTF_8)
|
||||||
|
val recordPayload = ByteArray(1 + langBytes.size + textBytes.size)
|
||||||
|
recordPayload[0] = langBytes.size.toByte()
|
||||||
|
langBytes.copyInto(recordPayload, 1)
|
||||||
|
textBytes.copyInto(recordPayload, 1 + langBytes.size)
|
||||||
|
|
||||||
|
val typeBytes = byteArrayOf(0x54)
|
||||||
|
val ndefRecord = byteArrayOf(
|
||||||
|
0xD1.toByte(),
|
||||||
|
typeBytes.size.toByte(),
|
||||||
|
recordPayload.size.toByte()
|
||||||
|
) + typeBytes + recordPayload
|
||||||
|
|
||||||
|
return byteArrayOf(0x00, ndefRecord.size.toByte()) + ndefRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
HceService.apduLogCallback = null
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.jrz.nfcapp.ui.read
|
||||||
|
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.nfc.Tag
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.animation.DecelerateInterpolator
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import com.jrz.nfcapp.R
|
||||||
|
import com.jrz.nfcapp.databinding.FragmentReadBinding
|
||||||
|
import com.jrz.nfcapp.model.TagDump
|
||||||
|
import com.jrz.nfcapp.util.NfcHelper
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class ReadFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentReadBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private var currentDump: TagDump? = null
|
||||||
|
private var pulseAnimator: ValueAnimator? = null
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentReadBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
binding.btnCopyUid.setOnClickListener {
|
||||||
|
currentDump?.let { dump ->
|
||||||
|
val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
cm.setPrimaryClip(ClipData.newPlainText("NFC UID", dump.formattedUid()))
|
||||||
|
Snackbar.make(binding.root, getString(R.string.uid_copied), Snackbar.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnSaveDump.setOnClickListener {
|
||||||
|
currentDump?.let { saveDump(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
startPulseAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
if (currentDump == null) startPulseAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
stopPulseAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPulseAnimation() {
|
||||||
|
pulseAnimator?.cancel()
|
||||||
|
val animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||||
|
duration = 2000
|
||||||
|
repeatCount = ValueAnimator.INFINITE
|
||||||
|
interpolator = DecelerateInterpolator()
|
||||||
|
|
||||||
|
addUpdateListener { anim ->
|
||||||
|
val p = anim.animatedValue as Float
|
||||||
|
|
||||||
|
// Ring 1: leads
|
||||||
|
val p1 = p
|
||||||
|
binding.ring1.alpha = (1f - p1) * 0.7f
|
||||||
|
binding.ring1.scaleX = 0.3f + p1 * 0.7f
|
||||||
|
binding.ring1.scaleY = 0.3f + p1 * 0.7f
|
||||||
|
|
||||||
|
// Ring 2: delayed by 33%
|
||||||
|
val p2 = ((p + 0.33f) % 1f)
|
||||||
|
binding.ring2.alpha = (1f - p2) * 0.5f
|
||||||
|
binding.ring2.scaleX = 0.3f + p2 * 0.7f
|
||||||
|
binding.ring2.scaleY = 0.3f + p2 * 0.7f
|
||||||
|
|
||||||
|
// Ring 3: delayed by 66%
|
||||||
|
val p3 = ((p + 0.66f) % 1f)
|
||||||
|
binding.ring3.alpha = (1f - p3) * 0.3f
|
||||||
|
binding.ring3.scaleX = 0.3f + p3 * 0.7f
|
||||||
|
binding.ring3.scaleY = 0.3f + p3 * 0.7f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animator.start()
|
||||||
|
pulseAnimator = animator
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopPulseAnimation() {
|
||||||
|
pulseAnimator?.cancel()
|
||||||
|
pulseAnimator = null
|
||||||
|
listOf(binding.ring1, binding.ring2, binding.ring3).forEach { it.alpha = 0f }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTagScanned(tag: Tag) {
|
||||||
|
try {
|
||||||
|
val dump = NfcHelper.readTag(tag)
|
||||||
|
currentDump = dump
|
||||||
|
requireActivity().runOnUiThread { displayDump(dump) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
requireActivity().runOnUiThread {
|
||||||
|
binding.tvStatus.text = getString(R.string.error_read, e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun displayDump(dump: TagDump) {
|
||||||
|
stopPulseAnimation()
|
||||||
|
|
||||||
|
binding.tvStatus.text = "Tag erkannt · ${dump.rawBytes.size} Bytes"
|
||||||
|
|
||||||
|
// UID
|
||||||
|
binding.cardUid.visibility = View.VISIBLE
|
||||||
|
binding.tvUid.text = dump.formattedUid()
|
||||||
|
|
||||||
|
// Tag Type
|
||||||
|
binding.cardTagType.visibility = View.VISIBLE
|
||||||
|
binding.tvTagType.text = dump.tagType
|
||||||
|
binding.tvTechList.text = dump.techList.joinToString("\n") { "• ${it.substringAfterLast('.')}" }
|
||||||
|
|
||||||
|
// NDEF
|
||||||
|
if (dump.ndefMessage != null || dump.ndefCapacity > 0) {
|
||||||
|
binding.cardNdef.visibility = View.VISIBLE
|
||||||
|
binding.tvNdefContent.text = buildString {
|
||||||
|
appendLine(dump.ndefContentSummary())
|
||||||
|
if (dump.ndefMaxSize > 0) {
|
||||||
|
appendLine("\nKapazität: ${dump.ndefCapacity} / ${dump.ndefMaxSize} Bytes")
|
||||||
|
append("Schreibbar: ${if (dump.isNdefWritable) "Ja" else "Nein"}")
|
||||||
|
}
|
||||||
|
}.trimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw + Diagnose log
|
||||||
|
binding.cardRaw.visibility = View.VISIBLE
|
||||||
|
binding.tvRawPages.text = buildString {
|
||||||
|
if (dump.rawPages.isNotEmpty()) {
|
||||||
|
appendLine(dump.rawPagesFormatted())
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
appendLine("── Diagnose ──")
|
||||||
|
append(dump.debugLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnSaveDump.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveDump(dump: TagDump) {
|
||||||
|
try {
|
||||||
|
val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
|
||||||
|
val filename = "jrz_dump_${sdf.format(Date())}.csv"
|
||||||
|
val dir = requireContext().getExternalFilesDir(null) ?: requireContext().filesDir
|
||||||
|
val file = File(dir, filename)
|
||||||
|
file.writeText(dump.toCsvDump())
|
||||||
|
Snackbar.make(binding.root, getString(R.string.dump_saved, file.absolutePath), Snackbar.LENGTH_LONG).show()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Snackbar.make(binding.root, "Speichern fehlgeschlagen: ${e.message}", Snackbar.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
stopPulseAnimation()
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.jrz.nfcapp.ui.write
|
||||||
|
|
||||||
|
import android.nfc.NdefMessage
|
||||||
|
import android.nfc.Tag
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import com.jrz.nfcapp.R
|
||||||
|
import com.jrz.nfcapp.databinding.FragmentWriteBinding
|
||||||
|
import com.jrz.nfcapp.util.NfcHelper
|
||||||
|
|
||||||
|
class WriteFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentWriteBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
var isWaitingForTag = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var pendingMessage: NdefMessage? = null
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentWriteBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
binding.etContent.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
updateByteCount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
binding.btnWrite.setOnClickListener { startWrite() }
|
||||||
|
binding.btnCancelWrite.setOnClickListener { cancelWrite() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateByteCount() {
|
||||||
|
val content = binding.etContent.text?.toString() ?: ""
|
||||||
|
val bytes = try {
|
||||||
|
buildMessage(content).byteArrayLength
|
||||||
|
} catch (_: Exception) { content.length }
|
||||||
|
binding.tvByteCount.text = "$bytes Bytes"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startWrite() {
|
||||||
|
val content = binding.etContent.text?.toString()?.trim() ?: ""
|
||||||
|
if (content.isEmpty()) {
|
||||||
|
Snackbar.make(binding.root, "Bitte Inhalt eingeben", Snackbar.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingMessage = try {
|
||||||
|
buildMessage(content)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Snackbar.make(binding.root, "Ungültiger Inhalt: ${e.message}", Snackbar.LENGTH_LONG).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isWaitingForTag = true
|
||||||
|
binding.btnWrite.isEnabled = false
|
||||||
|
binding.btnCancelWrite.visibility = View.VISIBLE
|
||||||
|
binding.writeStatusCard.visibility = View.VISIBLE
|
||||||
|
binding.tvWriteStatus.text = getString(R.string.write_waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelWrite() {
|
||||||
|
isWaitingForTag = false
|
||||||
|
pendingMessage = null
|
||||||
|
binding.btnWrite.isEnabled = true
|
||||||
|
binding.btnCancelWrite.visibility = View.GONE
|
||||||
|
binding.writeStatusCard.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTagScanned(tag: Tag) {
|
||||||
|
if (!isWaitingForTag) return
|
||||||
|
val msg = pendingMessage ?: return
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
when (NfcHelper.detectWriteMethod(tag)) {
|
||||||
|
NfcHelper.WriteMethod.NFC_V -> {
|
||||||
|
// NFC-V: erst als NDEF formatieren (NdefFormatable), dann NDEF schreiben
|
||||||
|
// Falls NdefFormatable vorhanden → format() macht beides in einem Schritt
|
||||||
|
NfcHelper.writeNdef(tag, msg)
|
||||||
|
}
|
||||||
|
NfcHelper.WriteMethod.MIFARE_CLASSIC ->
|
||||||
|
throw java.io.IOException("MIFARE Classic benötigt Schlüssel zum Schreiben")
|
||||||
|
NfcHelper.WriteMethod.UNSUPPORTED ->
|
||||||
|
throw java.io.IOException("Dieser Tag unterstützt kein Schreiben")
|
||||||
|
else -> NfcHelper.writeNdef(tag, msg)
|
||||||
|
}
|
||||||
|
requireActivity().runOnUiThread { onWriteSuccess() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
requireActivity().runOnUiThread { onWriteError(e.message ?: "Unbekannter Fehler") }
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onWriteSuccess() {
|
||||||
|
isWaitingForTag = false
|
||||||
|
pendingMessage = null
|
||||||
|
binding.btnWrite.isEnabled = true
|
||||||
|
binding.btnCancelWrite.visibility = View.GONE
|
||||||
|
binding.tvWriteStatus.text = getString(R.string.write_success)
|
||||||
|
binding.writeStatusCard.setCardBackgroundColor(resources.getColor(R.color.success_green, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onWriteError(error: String) {
|
||||||
|
isWaitingForTag = false
|
||||||
|
pendingMessage = null
|
||||||
|
binding.btnWrite.isEnabled = true
|
||||||
|
binding.btnCancelWrite.visibility = View.GONE
|
||||||
|
binding.tvWriteStatus.text = getString(R.string.error_write, error)
|
||||||
|
binding.writeStatusCard.setCardBackgroundColor(resources.getColor(R.color.error_red, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildMessage(content: String): NdefMessage = when {
|
||||||
|
binding.chipUrl.isChecked -> NfcHelper.buildUriNdef(content)
|
||||||
|
binding.chipHex.isChecked -> NfcHelper.buildRawNdef(content)
|
||||||
|
else -> NfcHelper.buildTextNdef(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package com.jrz.nfcapp.util
|
||||||
|
|
||||||
|
import android.nfc.NdefMessage
|
||||||
|
import android.nfc.NdefRecord
|
||||||
|
import android.nfc.Tag
|
||||||
|
import android.nfc.tech.*
|
||||||
|
import com.jrz.nfcapp.model.TagDump
|
||||||
|
import com.jrz.nfcapp.model.toHex
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
object NfcHelper {
|
||||||
|
|
||||||
|
// Standard MIFARE Classic default keys
|
||||||
|
private val MIFARE_DEFAULT_KEYS = listOf(
|
||||||
|
byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()),
|
||||||
|
byteArrayOf(0xA0.toByte(), 0xA1.toByte(), 0xA2.toByte(), 0xA3.toByte(), 0xA4.toByte(), 0xA5.toByte()),
|
||||||
|
byteArrayOf(0xD3.toByte(), 0xF7.toByte(), 0xD3.toByte(), 0xF7.toByte(), 0xD3.toByte(), 0xF7.toByte()),
|
||||||
|
byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00),
|
||||||
|
byteArrayOf(0xB0.toByte(), 0xB1.toByte(), 0xB2.toByte(), 0xB3.toByte(), 0xB4.toByte(), 0xB5.toByte()),
|
||||||
|
byteArrayOf(0x4D.toByte(), 0x3A.toByte(), 0x99.toByte(), 0xC3.toByte(), 0x51.toByte(), 0xDD.toByte()),
|
||||||
|
byteArrayOf(0x1A.toByte(), 0x98.toByte(), 0x2C.toByte(), 0x7E.toByte(), 0x45.toByte(), 0x9A.toByte()),
|
||||||
|
byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte(), 0xDD.toByte(), 0xEE.toByte(), 0xFF.toByte())
|
||||||
|
)
|
||||||
|
|
||||||
|
fun readTag(tag: Tag): TagDump {
|
||||||
|
val uid = tag.id.toHex()
|
||||||
|
val techList = tag.techList.toList()
|
||||||
|
var ndefMsg: NdefMessage? = null
|
||||||
|
var ndefCapacity = 0
|
||||||
|
var ndefMaxSize = 0
|
||||||
|
var ndefWritable = false
|
||||||
|
val rawPages = mutableListOf<ByteArray>()
|
||||||
|
var tagType = detectTagType(techList)
|
||||||
|
val log = StringBuilder()
|
||||||
|
|
||||||
|
log.appendLine("UID: $uid")
|
||||||
|
log.appendLine("Technologien: ${techList.map { it.substringAfterLast('.') }}")
|
||||||
|
|
||||||
|
// --- NDEF lesen ---
|
||||||
|
if (techList.contains(Ndef::class.java.name)) {
|
||||||
|
log.appendLine("→ Versuche NDEF lesen…")
|
||||||
|
val ndef = Ndef.get(tag)
|
||||||
|
try {
|
||||||
|
ndef.connect()
|
||||||
|
ndefMsg = ndef.cachedNdefMessage
|
||||||
|
if (ndefMsg == null) {
|
||||||
|
log.appendLine(" cachedNdefMessage null, lese live…")
|
||||||
|
ndefMsg = ndef.ndefMessage
|
||||||
|
}
|
||||||
|
ndefMaxSize = ndef.maxSize
|
||||||
|
ndefWritable = ndef.isWritable
|
||||||
|
ndefCapacity = ndef.maxSize
|
||||||
|
tagType = ndef.type ?: tagType
|
||||||
|
if (ndefMsg != null) {
|
||||||
|
log.appendLine(" NDEF OK: ${ndefMsg.records.size} Record(s), ${ndefMsg.byteArrayLength} Bytes")
|
||||||
|
} else {
|
||||||
|
log.appendLine(" NDEF leer (kein Inhalt)")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.appendLine(" NDEF Fehler: ${e.javaClass.simpleName}: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
try { ndef.close() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MIFARE Ultralight lesen ---
|
||||||
|
if (techList.contains(MifareUltralight::class.java.name)) {
|
||||||
|
log.appendLine("→ Versuche MifareUltralight lesen…")
|
||||||
|
val ul = MifareUltralight.get(tag)
|
||||||
|
try {
|
||||||
|
ul.connect()
|
||||||
|
val pageCount = when (ul.type) {
|
||||||
|
MifareUltralight.TYPE_ULTRALIGHT -> 16
|
||||||
|
MifareUltralight.TYPE_ULTRALIGHT_C -> 48
|
||||||
|
else -> 45 // NTAG213=45, NTAG215=135, NTAG216=231 — start conservative
|
||||||
|
}
|
||||||
|
log.appendLine(" Typ: ${ul.type}, lese $pageCount Pages…")
|
||||||
|
var readCount = 0
|
||||||
|
for (page in 0 until pageCount) {
|
||||||
|
try {
|
||||||
|
rawPages.add(ul.readPages(page).copyOfRange(0, 4))
|
||||||
|
readCount++
|
||||||
|
} catch (e: IOException) {
|
||||||
|
log.appendLine(" Stop bei Page $page: ${e.message}")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.appendLine(" $readCount Pages gelesen")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.appendLine(" Ultralight Fehler: ${e.javaClass.simpleName}: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
try { ul.close() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MIFARE Classic lesen (Default-Keys) ---
|
||||||
|
if (techList.contains(MifareClassic::class.java.name)) {
|
||||||
|
log.appendLine("→ Versuche MifareClassic mit Default-Keys…")
|
||||||
|
val mc = MifareClassic.get(tag)
|
||||||
|
try {
|
||||||
|
mc.connect()
|
||||||
|
val sectorCount = mc.sectorCount
|
||||||
|
log.appendLine(" ${mc.type} — $sectorCount Sektoren, ${mc.blockCount} Blöcke")
|
||||||
|
var unlockedSectors = 0
|
||||||
|
for (sector in 0 until sectorCount) {
|
||||||
|
var authenticated = false
|
||||||
|
for (key in MIFARE_DEFAULT_KEYS) {
|
||||||
|
try {
|
||||||
|
if (mc.authenticateSectorWithKeyA(sector, key) ||
|
||||||
|
mc.authenticateSectorWithKeyB(sector, key)) {
|
||||||
|
authenticated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
if (authenticated) {
|
||||||
|
unlockedSectors++
|
||||||
|
val blockCount = mc.getBlockCountInSector(sector)
|
||||||
|
val firstBlock = mc.sectorToBlock(sector)
|
||||||
|
for (b in 0 until blockCount) {
|
||||||
|
try {
|
||||||
|
val blockData = mc.readBlock(firstBlock + b)
|
||||||
|
rawPages.add(blockData)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
rawPages.add(ByteArray(16) { 0xFF.toByte() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.appendLine(" Sektor $sector: kein Default-Key passt (verschlüsselt)")
|
||||||
|
// Füge Platzhalt-Blöcke ein damit Sektornummern stimmen
|
||||||
|
val blockCount = mc.getBlockCountInSector(sector)
|
||||||
|
repeat(blockCount) { rawPages.add(ByteArray(16) { 0xEE.toByte() }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.appendLine(" $unlockedSectors/$sectorCount Sektoren entsperrt")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.appendLine(" MifareClassic Fehler: ${e.javaClass.simpleName}: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
try { mc.close() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NfcA Diagnostik (ATQA/SAK) ---
|
||||||
|
if (techList.contains(NfcA::class.java.name)) {
|
||||||
|
val nfcA = NfcA.get(tag)
|
||||||
|
try {
|
||||||
|
nfcA.connect()
|
||||||
|
val atqa = nfcA.atqa.toHex()
|
||||||
|
val sak = "%02X".format(nfcA.sak)
|
||||||
|
log.appendLine("→ NfcA: ATQA=$atqa SAK=$sak")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.appendLine("→ NfcA Diagnostik Fehler: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
try { nfcA.close() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NFC-V (ISO 15693) lesen ---
|
||||||
|
if (techList.contains(NfcV::class.java.name) && rawPages.isEmpty()) {
|
||||||
|
log.appendLine("→ Versuche NFC-V (ISO 15693) lesen…")
|
||||||
|
val nfcV = NfcV.get(tag)
|
||||||
|
try {
|
||||||
|
nfcV.connect()
|
||||||
|
val dsfId = nfcV.dsfId
|
||||||
|
val responseFlags = nfcV.responseFlags
|
||||||
|
log.appendLine(" DSFID=$dsfId ResponseFlags=$responseFlags")
|
||||||
|
|
||||||
|
// READ_SINGLE_BLOCK: flags=0x02, cmd=0x20, blockNum
|
||||||
|
// Flags 0x02 = high data rate, non-addressed mode
|
||||||
|
var block = 0
|
||||||
|
var consecutiveErrors = 0
|
||||||
|
while (block < 256 && consecutiveErrors < 3) {
|
||||||
|
try {
|
||||||
|
val cmd = byteArrayOf(0x02, 0x20, block.toByte())
|
||||||
|
val response = nfcV.transceive(cmd)
|
||||||
|
if (response.isNotEmpty() && (response[0].toInt() and 0x01) == 0) {
|
||||||
|
// Erfolg: response[0] = flags, rest = block data
|
||||||
|
val blockData = response.copyOfRange(1, response.size)
|
||||||
|
rawPages.add(blockData)
|
||||||
|
consecutiveErrors = 0
|
||||||
|
} else {
|
||||||
|
consecutiveErrors++
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
consecutiveErrors++
|
||||||
|
if (block == 0) {
|
||||||
|
log.appendLine(" Block 0 Fehler: ${e.message} — versuche addressed mode…")
|
||||||
|
// Versuche addressed mode (flags=0x22 + UID)
|
||||||
|
try {
|
||||||
|
val uidBytes = tag.id
|
||||||
|
val cmd = byteArrayOf(0x22, 0x20) + uidBytes + byteArrayOf(0x00)
|
||||||
|
val response = nfcV.transceive(cmd)
|
||||||
|
if (response.isNotEmpty() && (response[0].toInt() and 0x01) == 0) {
|
||||||
|
rawPages.add(response.copyOfRange(1, response.size))
|
||||||
|
consecutiveErrors = 0
|
||||||
|
log.appendLine(" Addressed mode erfolgreich")
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
block++
|
||||||
|
}
|
||||||
|
log.appendLine(" ${rawPages.size} Blöcke gelesen")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.appendLine(" NfcV Fehler: ${e.javaClass.simpleName}: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
try { nfcV.close() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IsoDep / ISO 14443-4 ---
|
||||||
|
if (techList.contains(IsoDep::class.java.name) && ndefMsg == null && rawPages.isEmpty()) {
|
||||||
|
log.appendLine("→ IsoDep erkannt — kein automatisches Lesen möglich (proprietäres Protokoll)")
|
||||||
|
}
|
||||||
|
|
||||||
|
val rawBytes = when {
|
||||||
|
rawPages.isNotEmpty() -> rawPages.flatMap { it.toList() }.toByteArray()
|
||||||
|
ndefMsg != null -> ndefMsg.toByteArray()
|
||||||
|
else -> ByteArray(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.appendLine("─── Gesamt: ${rawBytes.size} Bytes ───")
|
||||||
|
|
||||||
|
return TagDump(
|
||||||
|
uid = uid,
|
||||||
|
tagType = tagType,
|
||||||
|
techList = techList,
|
||||||
|
ndefMessage = ndefMsg,
|
||||||
|
ndefCapacity = ndefCapacity,
|
||||||
|
ndefMaxSize = ndefMaxSize,
|
||||||
|
isNdefWritable = ndefWritable,
|
||||||
|
rawPages = rawPages,
|
||||||
|
rawBytes = rawBytes,
|
||||||
|
debugLog = log.toString().trimEnd()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeNdef(tag: Tag, message: NdefMessage) {
|
||||||
|
if (tag.techList.contains(Ndef::class.java.name)) {
|
||||||
|
val ndef = Ndef.get(tag)
|
||||||
|
ndef.connect()
|
||||||
|
try {
|
||||||
|
if (!ndef.isWritable) throw IOException("Tag ist schreibgeschützt")
|
||||||
|
if (ndef.maxSize < message.byteArrayLength) {
|
||||||
|
throw IOException("Tag zu klein: ${ndef.maxSize} Bytes verfügbar, ${message.byteArrayLength} benötigt")
|
||||||
|
}
|
||||||
|
ndef.writeNdefMessage(message)
|
||||||
|
} finally {
|
||||||
|
ndef.close()
|
||||||
|
}
|
||||||
|
} else if (tag.techList.contains(NdefFormatable::class.java.name)) {
|
||||||
|
val formatable = NdefFormatable.get(tag)
|
||||||
|
formatable.connect()
|
||||||
|
try {
|
||||||
|
formatable.format(message)
|
||||||
|
} finally {
|
||||||
|
formatable.close()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw IOException("Tag unterstützt kein NDEF-Schreiben")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeUltralight(tag: Tag, pages: List<ByteArray>) {
|
||||||
|
val ul = MifareUltralight.get(tag) ?: throw IOException("Kein Ultralight-Tag")
|
||||||
|
ul.connect()
|
||||||
|
try {
|
||||||
|
pages.forEachIndexed { i, page ->
|
||||||
|
ul.writePage(i, page)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ul.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeNfcV(tag: Tag, blocks: List<ByteArray>) {
|
||||||
|
val nfcV = NfcV.get(tag) ?: throw IOException("Kein NFC-V Tag")
|
||||||
|
nfcV.connect()
|
||||||
|
try {
|
||||||
|
blocks.forEachIndexed { blockNum, data ->
|
||||||
|
// WRITE_SINGLE_BLOCK: Flags=0x02, Cmd=0x21, BlockNum, Data
|
||||||
|
val cmd = byteArrayOf(0x02, 0x21, blockNum.toByte()) + data
|
||||||
|
val response = nfcV.transceive(cmd)
|
||||||
|
if (response.isEmpty() || (response[0].toInt() and 0x01) != 0) {
|
||||||
|
val errCode = if (response.size >= 2) "%02X".format(response[1]) else "?"
|
||||||
|
throw IOException("Block $blockNum Fehler (ISO 15693 Error $errCode)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { nfcV.close() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detectWriteMethod(tag: Tag): WriteMethod {
|
||||||
|
val techs = tag.techList.toList()
|
||||||
|
return when {
|
||||||
|
techs.contains(NfcV::class.java.name) -> WriteMethod.NFC_V
|
||||||
|
techs.contains(MifareUltralight::class.java.name) -> WriteMethod.ULTRALIGHT
|
||||||
|
techs.contains(MifareClassic::class.java.name) -> WriteMethod.MIFARE_CLASSIC
|
||||||
|
techs.contains(Ndef::class.java.name) -> WriteMethod.NDEF
|
||||||
|
techs.contains(NdefFormatable::class.java.name) -> WriteMethod.NDEF_FORMATABLE
|
||||||
|
else -> WriteMethod.UNSUPPORTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class WriteMethod { NDEF, NDEF_FORMATABLE, ULTRALIGHT, NFC_V, MIFARE_CLASSIC, UNSUPPORTED }
|
||||||
|
|
||||||
|
fun buildTextNdef(text: String, locale: String = "de"): NdefMessage {
|
||||||
|
val langBytes = locale.toByteArray(Charsets.US_ASCII)
|
||||||
|
val textBytes = text.toByteArray(Charsets.UTF_8)
|
||||||
|
val payload = ByteArray(1 + langBytes.size + textBytes.size)
|
||||||
|
payload[0] = langBytes.size.toByte()
|
||||||
|
langBytes.copyInto(payload, 1)
|
||||||
|
textBytes.copyInto(payload, 1 + langBytes.size)
|
||||||
|
val record = NdefRecord(NdefRecord.TNF_WELL_KNOWN, NdefRecord.RTD_TEXT, ByteArray(0), payload)
|
||||||
|
return NdefMessage(arrayOf(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildUriNdef(uri: String): NdefMessage {
|
||||||
|
val record = NdefRecord.createUri(android.net.Uri.parse(uri))
|
||||||
|
return NdefMessage(arrayOf(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildRawNdef(hexPayload: String): NdefMessage {
|
||||||
|
val bytes = hexPayload.replace(" ", "").replace(":", "")
|
||||||
|
.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
|
val record = NdefRecord(NdefRecord.TNF_UNKNOWN, ByteArray(0), ByteArray(0), bytes)
|
||||||
|
return NdefMessage(arrayOf(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectTagType(techList: List<String>): String = when {
|
||||||
|
techList.contains(MifareUltralight::class.java.name) -> "MIFARE Ultralight / NTAG"
|
||||||
|
techList.contains(MifareClassic::class.java.name) -> "MIFARE Classic"
|
||||||
|
techList.contains(IsoDep::class.java.name) -> "ISO-DEP (ISO 14443-4)"
|
||||||
|
techList.contains(NfcF::class.java.name) -> "NFC-F (FeliCa)"
|
||||||
|
techList.contains(NfcV::class.java.name) -> "NFC-V (ISO 15693)"
|
||||||
|
techList.contains(Ndef::class.java.name) -> "NDEF"
|
||||||
|
techList.contains(NfcA::class.java.name) -> "NFC-A (ISO 14443-3A)"
|
||||||
|
techList.contains(NfcB::class.java.name) -> "NFC-B (ISO 14443-3B)"
|
||||||
|
else -> "Unbekannt"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="@color/card_dark" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
<stroke android:width="1dp" android:color="@color/divider" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<!-- Left accent border -->
|
||||||
|
<item android:right="99999dp">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="@color/accent_cyan" />
|
||||||
|
<corners android:topLeftRadius="12dp" android:bottomLeftRadius="12dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="@color/accent_cyan_alpha10" />
|
||||||
|
<stroke
|
||||||
|
android:width="1.5dp"
|
||||||
|
android:color="@color/accent_cyan_alpha20" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/error_alpha20" />
|
||||||
|
<stroke android:width="1dp" android:color="@color/error_red" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/success_alpha20" />
|
||||||
|
<stroke android:width="1dp" android:color="@color/success" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/accent_cyan_alpha10" />
|
||||||
|
<stroke android:width="1dp" android:color="@color/accent_cyan_alpha20" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="@color/success" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="@color/error_red" />
|
||||||
|
</shape>
|
||||||
|
After Width: | Height: | Size: 4.4 MiB |
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="48dp"
|
||||||
|
android:height="48dp"
|
||||||
|
android:viewportWidth="48"
|
||||||
|
android:viewportHeight="48">
|
||||||
|
|
||||||
|
<!-- Center dot -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00D1FF"
|
||||||
|
android:pathData="M22,24 A2,2 0 1,1 26,24 A2,2 0 1,1 22,24" />
|
||||||
|
|
||||||
|
<!-- Arc 1 - inner -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#00D1FF"
|
||||||
|
android:strokeWidth="2.5"
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M28,17.5 A8.5,8.5 0 0,1 28,30.5" />
|
||||||
|
|
||||||
|
<!-- Arc 2 - middle -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#00D1FF"
|
||||||
|
android:strokeWidth="2.5"
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeAlpha="0.7"
|
||||||
|
android:pathData="M32,13 A13,13 0 0,1 32,35" />
|
||||||
|
|
||||||
|
<!-- Arc 3 - outer -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#00D1FF"
|
||||||
|
android:strokeWidth="2.5"
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeAlpha="0.4"
|
||||||
|
android:pathData="M36,8.5 A17.5,17.5 0 0,1 36,39.5" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="@android:color/transparent" />
|
||||||
|
<stroke
|
||||||
|
android:width="2dp"
|
||||||
|
android:color="@color/accent_cyan" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:id="@+id/rootLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/bg_dark"
|
||||||
|
android:fitsSystemWindows="true">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:id="@+id/appBarLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@color/surface_dark"
|
||||||
|
android:fitsSystemWindows="true"
|
||||||
|
app:elevation="0dp">
|
||||||
|
|
||||||
|
<!-- Brand Header -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="72dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="20dp"
|
||||||
|
android:paddingEnd="20dp">
|
||||||
|
|
||||||
|
<!-- NFC Icon -->
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="36dp"
|
||||||
|
android:layout_height="36dp"
|
||||||
|
android:src="@drawable/ic_nfc"
|
||||||
|
android:contentDescription="NFC" />
|
||||||
|
|
||||||
|
<!-- Brand Text -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_marginStart="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/app_name_brand"
|
||||||
|
android:textColor="@color/accent_cyan"
|
||||||
|
android:textSize="22sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:letterSpacing="0.05" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/app_name_sub"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:letterSpacing="0.2" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- NFC Status Dot -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/nfcStatusBadge"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:background="@drawable/bg_status_waiting"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="5dp">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/nfcStatusDot"
|
||||||
|
android:layout_width="7dp"
|
||||||
|
android:layout_height="7dp"
|
||||||
|
android:background="@drawable/dot_active" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNfcStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:text="NFC"
|
||||||
|
android:textColor="@color/accent_cyan"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:letterSpacing="0.1" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Divider line under header -->
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="@color/divider" />
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<com.google.android.material.tabs.TabLayout
|
||||||
|
android:id="@+id/tabLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:background="@color/surface_dark"
|
||||||
|
app:tabMode="fixed"
|
||||||
|
app:tabGravity="fill"
|
||||||
|
app:tabTextColor="@color/text_secondary"
|
||||||
|
app:tabSelectedTextColor="@color/accent_cyan"
|
||||||
|
app:tabIndicatorColor="@color/accent_cyan"
|
||||||
|
app:tabIndicatorHeight="2dp"
|
||||||
|
app:tabIndicatorFullWidth="false"
|
||||||
|
app:tabTextAppearance="@style/JrzTabText" />
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<androidx.viewpager2.widget.ViewPager2
|
||||||
|
android:id="@+id/viewPager"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/bg_dark"
|
||||||
|
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/bg_dark"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:paddingTop="20dp"
|
||||||
|
android:paddingBottom="24dp">
|
||||||
|
|
||||||
|
<!-- Step 1 Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardStep1"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
app:cardBackgroundColor="@color/card_dark"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/accent_cyan_alpha20"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<!-- Step Label -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="28dp"
|
||||||
|
android:layout_height="28dp"
|
||||||
|
android:text="1"
|
||||||
|
android:textColor="@color/bg_dark"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textAlignment="center"
|
||||||
|
android:gravity="center"
|
||||||
|
android:background="@drawable/bg_nfc_circle" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:text="@string/clone_step1"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSourceStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:text="@string/clone_no_data"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
<!-- Source Details (hidden until read) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutSourceDetails"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:background="@drawable/bg_status_waiting"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSourceUid"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="@color/accent_cyan"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSourceType"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSourceSize"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="@color/text_mono"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnReadSource"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/clone_read_source_btn"
|
||||||
|
android:textColor="@color/bg_dark"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:backgroundTint="@color/accent_cyan"
|
||||||
|
app:cornerRadius="10dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Connector Arrow -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="1dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/divider" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Step 2 Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardStep2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardBackgroundColor="@color/card_dark"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/divider"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<!-- Step Label -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="28dp"
|
||||||
|
android:layout_height="28dp"
|
||||||
|
android:text="2"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textAlignment="center"
|
||||||
|
android:gravity="center"
|
||||||
|
android:background="@drawable/ring_pulse" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:text="@string/clone_step2"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTargetStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnWriteTarget"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:enabled="false"
|
||||||
|
android:text="@string/clone_write_target_btn"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:cornerRadius="10dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- UID Warning -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardBackgroundColor="@color/warning_alpha20"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/warning_orange"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:textColor="@color/warning_orange"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:lineSpacingExtra="2dp"
|
||||||
|
android:text="UID wird NICHT geklont. UID-basierte Zugangssysteme erkennen den Klon nicht." />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/bg_dark"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:paddingTop="20dp"
|
||||||
|
android:paddingBottom="24dp">
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/hceStatusCard"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardBackgroundColor="@color/card_dark"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/divider"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/hceStatusDot"
|
||||||
|
android:layout_width="10dp"
|
||||||
|
android:layout_height="10dp"
|
||||||
|
android:background="@drawable/dot_inactive" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvHceStatus"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:text="@string/hce_status_inactive"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="HCE"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:letterSpacing="0.15" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Config Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
app:cardBackgroundColor="@color/card_dark"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/divider"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="KONFIGURATION"
|
||||||
|
style="@style/JrzSectionLabel"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilAid"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:hint="@string/hce_aid_hint"
|
||||||
|
app:helperText="Standard: D2760000850101 (NDEF)"
|
||||||
|
app:boxStrokeColor="@color/accent_cyan_alpha20"
|
||||||
|
app:hintTextColor="@color/text_secondary">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etAid"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="@color/accent_cyan"
|
||||||
|
android:inputType="textCapCharacters|text"
|
||||||
|
android:text="D2760000850101" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilPayload"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/hce_payload_hint"
|
||||||
|
app:boxStrokeColor="@color/accent_cyan_alpha20"
|
||||||
|
app:hintTextColor="@color/text_secondary">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etPayload"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minLines="2"
|
||||||
|
android:gravity="top"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:inputType="textMultiLine" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnEnableHce"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="52dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:text="@string/hce_enable_btn"
|
||||||
|
android:textColor="@color/bg_dark"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:letterSpacing="0.08"
|
||||||
|
app:backgroundTint="@color/accent_cyan"
|
||||||
|
app:cornerRadius="12dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnDisableHce"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginBottom="20dp"
|
||||||
|
android:text="@string/hce_disable_btn"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:enabled="false"
|
||||||
|
app:strokeColor="@color/divider"
|
||||||
|
app:cornerRadius="12dp" />
|
||||||
|
|
||||||
|
<!-- Warning -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardBackgroundColor="@color/warning_alpha20"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/warning_orange"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:textColor="@color/warning_orange"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:lineSpacingExtra="2dp"
|
||||||
|
android:text="@string/hce_warning" />
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- APDU Log -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="APDU-LOG"
|
||||||
|
style="@style/JrzSectionLabel"
|
||||||
|
android:textColor="@color/accent_cyan" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnClearLog"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Leeren"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardApduLog"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardBackgroundColor="@color/log_background"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/accent_cyan_alpha10"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvApduLog"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="@color/log_text"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:lineSpacingExtra="3dp"
|
||||||
|
android:minLines="6"
|
||||||
|
android:text="Kein APDU-Verkehr…" />
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/bg_dark"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:paddingTop="20dp"
|
||||||
|
android:paddingBottom="24dp">
|
||||||
|
|
||||||
|
<!-- NFC Scan Area -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/scanArea"
|
||||||
|
android:layout_width="180dp"
|
||||||
|
android:layout_height="180dp"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:layout_marginBottom="16dp">
|
||||||
|
|
||||||
|
<!-- Pulse rings (animated in code) -->
|
||||||
|
<View
|
||||||
|
android:id="@+id/ring3"
|
||||||
|
android:layout_width="180dp"
|
||||||
|
android:layout_height="180dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:background="@drawable/ring_pulse"
|
||||||
|
android:alpha="0" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/ring2"
|
||||||
|
android:layout_width="140dp"
|
||||||
|
android:layout_height="140dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:background="@drawable/ring_pulse"
|
||||||
|
android:alpha="0" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/ring1"
|
||||||
|
android:layout_width="100dp"
|
||||||
|
android:layout_height="100dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:background="@drawable/ring_pulse"
|
||||||
|
android:alpha="0" />
|
||||||
|
|
||||||
|
<!-- Center circle -->
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="72dp"
|
||||||
|
android:layout_height="72dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:background="@drawable/bg_nfc_circle">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivNfcIcon"
|
||||||
|
android:layout_width="44dp"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:src="@drawable/ic_nfc"
|
||||||
|
android:contentDescription="NFC" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<!-- Status Text -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="20dp"
|
||||||
|
android:text="@string/read_instruction"
|
||||||
|
android:textAlignment="center"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:letterSpacing="0.04" />
|
||||||
|
|
||||||
|
<!-- UID Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardUid"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cardBackgroundColor="@color/card_dark"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/accent_cyan_alpha20"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="12dp"
|
||||||
|
android:paddingBottom="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="UID"
|
||||||
|
style="@style/JrzSectionLabel" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginTop="6dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvUid"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="@color/accent_cyan"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:letterSpacing="0.05" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnCopyUid"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:contentDescription="@string/read_copy_uid"
|
||||||
|
app:icon="@android:drawable/ic_menu_share"
|
||||||
|
app:iconTint="@color/text_secondary" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Tag Type Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardTagType"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cardBackgroundColor="@color/card_dark"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/divider"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="TAG-TYP"
|
||||||
|
style="@style/JrzSectionLabel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTagType"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:background="@color/divider" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="TECHNOLOGIEN"
|
||||||
|
style="@style/JrzSectionLabel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTechList"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="@color/text_mono"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:lineSpacingExtra="4dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- NDEF Content Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardNdef"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cardBackgroundColor="@color/card_dark"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/divider"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="NDEF-INHALT"
|
||||||
|
style="@style/JrzSectionLabel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNdefContent"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="@color/text_mono"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:lineSpacingExtra="3dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Raw / Diagnose Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardRaw"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cardBackgroundColor="@color/log_background"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/divider"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="ROHDATEN / DIAGNOSE"
|
||||||
|
style="@style/JrzSectionLabel"
|
||||||
|
android:textColor="@color/accent_cyan" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvRawPages"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="@color/log_text"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:lineSpacingExtra="2dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Save Button -->
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSaveDump"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="52dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/read_save_btn"
|
||||||
|
android:textColor="@color/bg_dark"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:letterSpacing="0.08"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:backgroundTint="@color/accent_cyan"
|
||||||
|
app:cornerRadius="12dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/bg_dark"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:paddingTop="20dp"
|
||||||
|
android:paddingBottom="24dp">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="NACHRICHTENTYP"
|
||||||
|
style="@style/JrzSectionLabel"
|
||||||
|
android:layout_marginBottom="10dp" />
|
||||||
|
|
||||||
|
<!-- Type Chips -->
|
||||||
|
<com.google.android.material.chip.ChipGroup
|
||||||
|
android:id="@+id/chipGroupType"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="20dp"
|
||||||
|
app:selectionRequired="true"
|
||||||
|
app:singleSelection="true">
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chipText"
|
||||||
|
style="@style/Widget.Material3.Chip.Filter"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Text (de)"
|
||||||
|
android:checked="true"
|
||||||
|
app:checkedIconVisible="false" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chipUrl"
|
||||||
|
style="@style/Widget.Material3.Chip.Filter"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="URL"
|
||||||
|
app:checkedIconVisible="false" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chipHex"
|
||||||
|
style="@style/Widget.Material3.Chip.Filter"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Roh-Hex"
|
||||||
|
app:checkedIconVisible="false" />
|
||||||
|
|
||||||
|
</com.google.android.material.chip.ChipGroup>
|
||||||
|
|
||||||
|
<!-- Input Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
app:cardBackgroundColor="@color/card_dark"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/divider"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilContent"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/write_content_hint"
|
||||||
|
app:boxStrokeColor="@color/accent_cyan_alpha20"
|
||||||
|
app:hintTextColor="@color/text_secondary">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etContent"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minLines="3"
|
||||||
|
android:gravity="top"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:inputType="textMultiLine" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvByteCount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:text="0 Bytes"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:fontFamily="monospace" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Write Button -->
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnWrite"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="52dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/write_btn"
|
||||||
|
android:textColor="@color/bg_dark"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:letterSpacing="0.08"
|
||||||
|
app:backgroundTint="@color/accent_cyan"
|
||||||
|
app:cornerRadius="12dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnCancelWrite"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/write_cancel_btn"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:strokeColor="@color/divider"
|
||||||
|
app:cornerRadius="12dp" />
|
||||||
|
|
||||||
|
<!-- Status Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/writeStatusCard"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cardBackgroundColor="@color/card_dark"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/accent_cyan_alpha20"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvWriteStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:textAlignment="center"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Warning Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardFormatWarning"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cardBackgroundColor="@color/warning_alpha20"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:strokeColor="@color/warning_orange"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFormatWarning"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:textColor="@color/warning_orange"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/bg_dark" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_fg" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/bg_dark" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_fg" />
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Brand / Background -->
|
||||||
|
<color name="bg_dark">#08091A</color>
|
||||||
|
<color name="surface_dark">#0D1128</color>
|
||||||
|
<color name="card_dark">#111827</color>
|
||||||
|
<color name="card_elevated">#162035</color>
|
||||||
|
<color name="divider">#1E2D40</color>
|
||||||
|
|
||||||
|
<!-- Accent -->
|
||||||
|
<color name="accent_cyan">#00D1FF</color>
|
||||||
|
<color name="accent_cyan_alpha20">#3300D1FF</color>
|
||||||
|
<color name="accent_cyan_alpha10">#1A00D1FF</color>
|
||||||
|
<color name="accent_purple">#7C3AED</color>
|
||||||
|
<color name="accent_purple_alpha20">#337C3AED</color>
|
||||||
|
|
||||||
|
<!-- Text -->
|
||||||
|
<color name="text_primary">#E2E8F0</color>
|
||||||
|
<color name="text_secondary">#64748B</color>
|
||||||
|
<color name="text_mono">#94A3B8</color>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<color name="success">#10B981</color>
|
||||||
|
<color name="success_alpha20">#3310B981</color>
|
||||||
|
<color name="error_red">#EF4444</color>
|
||||||
|
<color name="error_alpha20">#33EF4444</color>
|
||||||
|
<color name="warning_orange">#F59E0B</color>
|
||||||
|
<color name="warning_alpha20">#33F59E0B</color>
|
||||||
|
|
||||||
|
<!-- Terminal / Log -->
|
||||||
|
<color name="log_background">#050812</color>
|
||||||
|
<color name="log_text">#00D1FF</color>
|
||||||
|
|
||||||
|
<!-- Legacy (still used in code) -->
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="success_green">#10B981</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">JRZ NFC Cloner</string>
|
||||||
|
<string name="app_name_brand">JRZ</string>
|
||||||
|
<string name="app_name_sub">NFC CLONER</string>
|
||||||
|
<string name="tab_read">Lesen</string>
|
||||||
|
<string name="tab_write">Schreiben</string>
|
||||||
|
<string name="tab_clone">Klonen</string>
|
||||||
|
<string name="tab_hce">HCE</string>
|
||||||
|
<string name="hce_service_description">NFC HCE Emulation</string>
|
||||||
|
<string name="hce_aid_group_description">NFC Tag Emulation</string>
|
||||||
|
|
||||||
|
<!-- Read Fragment -->
|
||||||
|
<string name="read_instruction">Halte einen NFC-Tag ans Telefon</string>
|
||||||
|
<string name="read_uid">UID</string>
|
||||||
|
<string name="read_tag_type">Tag-Typ</string>
|
||||||
|
<string name="read_technologies">Technologien</string>
|
||||||
|
<string name="read_ndef_content">NDEF-Inhalt</string>
|
||||||
|
<string name="read_raw_pages">Rohdaten (Seiten)</string>
|
||||||
|
<string name="read_save_btn">Tag-Dump speichern</string>
|
||||||
|
<string name="read_copy_uid">UID kopieren</string>
|
||||||
|
|
||||||
|
<!-- Write Fragment -->
|
||||||
|
<string name="write_instruction">NDEF-Nachricht eingeben, dann Tag halten</string>
|
||||||
|
<string name="write_type_text">Text (de)</string>
|
||||||
|
<string name="write_type_url">URL</string>
|
||||||
|
<string name="write_type_hex">Roh-Hex</string>
|
||||||
|
<string name="write_content_hint">Inhalt eingeben…</string>
|
||||||
|
<string name="write_btn">Schreiben starten</string>
|
||||||
|
<string name="write_cancel_btn">Abbrechen</string>
|
||||||
|
<string name="write_waiting">Warte auf Tag…</string>
|
||||||
|
<string name="write_success">Erfolgreich geschrieben!</string>
|
||||||
|
<string name="write_readonly_error">Tag ist schreibgeschützt</string>
|
||||||
|
<string name="write_size_error">Inhalt zu groß für diesen Tag</string>
|
||||||
|
|
||||||
|
<!-- Clone Fragment -->
|
||||||
|
<string name="clone_step1">Schritt 1: Quell-Tag lesen</string>
|
||||||
|
<string name="clone_step2">Schritt 2: Ziel-Tag beschreiben</string>
|
||||||
|
<string name="clone_read_source_btn">Quelle scannen</string>
|
||||||
|
<string name="clone_write_target_btn">Auf Ziel schreiben</string>
|
||||||
|
<string name="clone_no_data">Noch kein Quell-Tag gelesen</string>
|
||||||
|
<string name="clone_source_ready">Quelle bereit – %1$d Bytes</string>
|
||||||
|
<string name="clone_waiting_source">Halte Quell-Tag ans Telefon…</string>
|
||||||
|
<string name="clone_waiting_target">Halte Ziel-Tag ans Telefon…</string>
|
||||||
|
<string name="clone_success">Klon erfolgreich!</string>
|
||||||
|
|
||||||
|
<!-- HCE Fragment -->
|
||||||
|
<string name="hce_instruction">Emuliert einen NDEF-Tag via HCE.\nFunktioniert nur bei Systemen die AIDs prüfen, NICHT bei UID-basierter Prüfung.</string>
|
||||||
|
<string name="hce_aid_hint">AID (Hex, z.B. D2760000850101)</string>
|
||||||
|
<string name="hce_payload_hint">NDEF-Payload (Text oder Hex)</string>
|
||||||
|
<string name="hce_enable_btn">HCE aktivieren</string>
|
||||||
|
<string name="hce_disable_btn">HCE deaktivieren</string>
|
||||||
|
<string name="hce_status_active">HCE aktiv</string>
|
||||||
|
<string name="hce_status_inactive">HCE inaktiv</string>
|
||||||
|
<string name="hce_apdu_log">APDU-Log</string>
|
||||||
|
<string name="hce_warning">Hinweis: UID-basierte Zugangssysteme können nicht per HCE emuliert werden.</string>
|
||||||
|
|
||||||
|
<!-- Errors / Status -->
|
||||||
|
<string name="nfc_not_supported">NFC wird auf diesem Gerät nicht unterstützt</string>
|
||||||
|
<string name="nfc_disabled">NFC ist deaktiviert. Bitte in den Einstellungen aktivieren.</string>
|
||||||
|
<string name="error_connect">Verbindung zum Tag fehlgeschlagen</string>
|
||||||
|
<string name="error_read">Lesefehler: %1$s</string>
|
||||||
|
<string name="error_write">Schreibfehler: %1$s</string>
|
||||||
|
<string name="uid_copied">UID in Zwischenablage kopiert</string>
|
||||||
|
<string name="dump_saved">Dump gespeichert: %1$s</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.NFCApp" parent="Theme.Material3.Dark.NoActionBar">
|
||||||
|
<item name="colorPrimary">@color/accent_cyan</item>
|
||||||
|
<item name="colorPrimaryContainer">@color/card_elevated</item>
|
||||||
|
<item name="colorOnPrimaryContainer">@color/accent_cyan</item>
|
||||||
|
<item name="colorSecondary">@color/accent_purple</item>
|
||||||
|
<item name="colorSecondaryContainer">@color/accent_purple_alpha20</item>
|
||||||
|
<item name="colorSurface">@color/surface_dark</item>
|
||||||
|
<item name="colorSurfaceVariant">@color/card_dark</item>
|
||||||
|
<item name="android:colorBackground">@color/bg_dark</item>
|
||||||
|
<item name="colorOnSurface">@color/text_primary</item>
|
||||||
|
<item name="colorOnSurfaceVariant">@color/text_secondary</item>
|
||||||
|
<item name="colorOutline">@color/divider</item>
|
||||||
|
|
||||||
|
<!-- Edge-to-edge: transparent bars -->
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
<item name="android:windowLightNavigationBar">false</item>
|
||||||
|
|
||||||
|
<!-- Tab indicator color -->
|
||||||
|
<item name="tabIndicatorColor">@color/accent_cyan</item>
|
||||||
|
|
||||||
|
<!-- Card style -->
|
||||||
|
<item name="materialCardViewStyle">@style/JrzCardView</item>
|
||||||
|
|
||||||
|
<!-- Chip style override -->
|
||||||
|
<item name="chipStyle">@style/JrzChip</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="JrzCardView" parent="Widget.Material3.CardView.Elevated">
|
||||||
|
<item name="cardBackgroundColor">@color/card_dark</item>
|
||||||
|
<item name="cardCornerRadius">12dp</item>
|
||||||
|
<item name="cardElevation">0dp</item>
|
||||||
|
<item name="strokeColor">@color/divider</item>
|
||||||
|
<item name="strokeWidth">1dp</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="JrzChip" parent="Widget.Material3.Chip.Filter">
|
||||||
|
<item name="android:textColor">@color/text_secondary</item>
|
||||||
|
<item name="checkedIconVisible">false</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="JrzSectionLabel" parent="TextAppearance.Material3.LabelSmall">
|
||||||
|
<item name="android:textColor">@color/text_secondary</item>
|
||||||
|
<item name="android:textAllCaps">true</item>
|
||||||
|
<item name="android:letterSpacing">0.12</item>
|
||||||
|
<item name="android:textSize">11sp</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="JrzMonoValue" parent="TextAppearance.Material3.BodyMedium">
|
||||||
|
<item name="android:fontFamily">monospace</item>
|
||||||
|
<item name="android:textColor">@color/text_primary</item>
|
||||||
|
<item name="android:textSize">14sp</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="JrzTabText" parent="TextAppearance.Material3.LabelLarge">
|
||||||
|
<item name="android:textSize">12sp</item>
|
||||||
|
<item name="android:letterSpacing">0.08</item>
|
||||||
|
<item name="android:textAllCaps">true</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
AID-Gruppen für HCE.
|
||||||
|
Die Standard-AID hier ist ein Platzhalter (D2760000850101 = NDEF-AID).
|
||||||
|
Wird zur Laufzeit über HceService dynamisch konfiguriert.
|
||||||
|
-->
|
||||||
|
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:description="@string/hce_service_description"
|
||||||
|
android:requireDeviceUnlock="false">
|
||||||
|
|
||||||
|
<aid-group android:category="other" android:description="@string/hce_aid_group_description">
|
||||||
|
<!-- NDEF Application AID -->
|
||||||
|
<aid-filter android:name="D2760000850101" />
|
||||||
|
</aid-group>
|
||||||
|
|
||||||
|
</host-apdu-service>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<tech-list>
|
||||||
|
<tech>android.nfc.tech.Ndef</tech>
|
||||||
|
</tech-list>
|
||||||
|
<tech-list>
|
||||||
|
<tech>android.nfc.tech.NdefFormatable</tech>
|
||||||
|
</tech-list>
|
||||||
|
<tech-list>
|
||||||
|
<tech>android.nfc.tech.MifareUltralight</tech>
|
||||||
|
</tech-list>
|
||||||
|
<tech-list>
|
||||||
|
<tech>android.nfc.tech.MifareClassic</tech>
|
||||||
|
</tech-list>
|
||||||
|
<tech-list>
|
||||||
|
<tech>android.nfc.tech.NfcA</tech>
|
||||||
|
</tech-list>
|
||||||
|
<tech-list>
|
||||||
|
<tech>android.nfc.tech.NfcB</tech>
|
||||||
|
</tech-list>
|
||||||
|
<tech-list>
|
||||||
|
<tech>android.nfc.tech.NfcF</tech>
|
||||||
|
</tech-list>
|
||||||
|
<tech-list>
|
||||||
|
<tech>android.nfc.tech.NfcV</tech>
|
||||||
|
</tech-list>
|
||||||
|
<tech-list>
|
||||||
|
<tech>android.nfc.tech.IsoDep</tech>
|
||||||
|
</tech-list>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.5.2"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
coreKtx = "1.13.1"
|
||||||
|
appcompat = "1.7.0"
|
||||||
|
material = "1.12.0"
|
||||||
|
constraintlayout = "2.1.4"
|
||||||
|
lifecycle = "2.8.6"
|
||||||
|
navigation = "2.8.3"
|
||||||
|
viewpager2 = "1.1.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
|
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||||
|
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" }
|
||||||
|
androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" }
|
||||||
|
androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "NFCApp"
|
||||||
|
include(":app")
|
||||||