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")
|
||||