commit f6bf1cb98f07899f2392cbe46ccf7c4f914f27d9 Author: Manuel Kirchebner Date: Tue Apr 21 15:21:53 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0f5f10 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..3bf5ac2 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d838ea3 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/jrz/nfcapp/model/TagDump.kt b/app/src/main/java/com/jrz/nfcapp/model/TagDump.kt new file mode 100644 index 0000000..d8f0249 --- /dev/null +++ b/app/src/main/java/com/jrz/nfcapp/model/TagDump.kt @@ -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, + val ndefMessage: NdefMessage?, + val ndefCapacity: Int, + val ndefMaxSize: Int, + val isNdefWritable: Boolean, + val rawPages: List, + 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() } +} diff --git a/app/src/main/java/com/jrz/nfcapp/service/HceService.kt b/app/src/main/java/com/jrz/nfcapp/service/HceService.kt new file mode 100644 index 0000000..51f9254 --- /dev/null +++ b/app/src/main/java/com/jrz/nfcapp/service/HceService.kt @@ -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 + } +} diff --git a/app/src/main/java/com/jrz/nfcapp/ui/MainActivity.kt b/app/src/main/java/com/jrz/nfcapp/ui/MainActivity.kt new file mode 100644 index 0000000..a1a3be3 --- /dev/null +++ b/app/src/main/java/com/jrz/nfcapp/ui/MainActivity.kt @@ -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(NfcAdapter.EXTRA_TAG) ?: return + when (binding.viewPager.currentItem) { + 0 -> readFragment.onTagScanned(tag) + 1 -> writeFragment.onTagScanned(tag) + 2 -> cloneFragment.onTagScanned(tag) + } + } +} diff --git a/app/src/main/java/com/jrz/nfcapp/ui/clone/CloneFragment.kt b/app/src/main/java/com/jrz/nfcapp/ui/clone/CloneFragment.kt new file mode 100644 index 0000000..8c7fc18 --- /dev/null +++ b/app/src/main/java/com/jrz/nfcapp/ui/clone/CloneFragment.kt @@ -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 + } +} diff --git a/app/src/main/java/com/jrz/nfcapp/ui/hce/HceFragment.kt b/app/src/main/java/com/jrz/nfcapp/ui/hce/HceFragment.kt new file mode 100644 index 0000000..5c24d3e --- /dev/null +++ b/app/src/main/java/com/jrz/nfcapp/ui/hce/HceFragment.kt @@ -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(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 + } +} diff --git a/app/src/main/java/com/jrz/nfcapp/ui/read/ReadFragment.kt b/app/src/main/java/com/jrz/nfcapp/ui/read/ReadFragment.kt new file mode 100644 index 0000000..e7cc451 --- /dev/null +++ b/app/src/main/java/com/jrz/nfcapp/ui/read/ReadFragment.kt @@ -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 + } +} diff --git a/app/src/main/java/com/jrz/nfcapp/ui/write/WriteFragment.kt b/app/src/main/java/com/jrz/nfcapp/ui/write/WriteFragment.kt new file mode 100644 index 0000000..8304636 --- /dev/null +++ b/app/src/main/java/com/jrz/nfcapp/ui/write/WriteFragment.kt @@ -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 + } +} diff --git a/app/src/main/java/com/jrz/nfcapp/util/NfcHelper.kt b/app/src/main/java/com/jrz/nfcapp/util/NfcHelper.kt new file mode 100644 index 0000000..53b74d5 --- /dev/null +++ b/app/src/main/java/com/jrz/nfcapp/util/NfcHelper.kt @@ -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() + 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) { + 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) { + 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 = 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" + } +} diff --git a/app/src/main/res/drawable/bg_card_accent.xml b/app/src/main/res/drawable/bg_card_accent.xml new file mode 100644 index 0000000..aa90880 --- /dev/null +++ b/app/src/main/res/drawable/bg_card_accent.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_nfc_circle.xml b/app/src/main/res/drawable/bg_nfc_circle.xml new file mode 100644 index 0000000..79d0b56 --- /dev/null +++ b/app/src/main/res/drawable/bg_nfc_circle.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_status_error.xml b/app/src/main/res/drawable/bg_status_error.xml new file mode 100644 index 0000000..36388cf --- /dev/null +++ b/app/src/main/res/drawable/bg_status_error.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_status_success.xml b/app/src/main/res/drawable/bg_status_success.xml new file mode 100644 index 0000000..042e346 --- /dev/null +++ b/app/src/main/res/drawable/bg_status_success.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_status_waiting.xml b/app/src/main/res/drawable/bg_status_waiting.xml new file mode 100644 index 0000000..0f799ce --- /dev/null +++ b/app/src/main/res/drawable/bg_status_waiting.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/dot_active.xml b/app/src/main/res/drawable/dot_active.xml new file mode 100644 index 0000000..57eed69 --- /dev/null +++ b/app/src/main/res/drawable/dot_active.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/dot_inactive.xml b/app/src/main/res/drawable/dot_inactive.xml new file mode 100644 index 0000000..b1c7fe1 --- /dev/null +++ b/app/src/main/res/drawable/dot_inactive.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_fg.png b/app/src/main/res/drawable/ic_launcher_fg.png new file mode 100644 index 0000000..8368530 Binary files /dev/null and b/app/src/main/res/drawable/ic_launcher_fg.png differ diff --git a/app/src/main/res/drawable/ic_nfc.xml b/app/src/main/res/drawable/ic_nfc.xml new file mode 100644 index 0000000..56b96e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_nfc.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ring_pulse.xml b/app/src/main/res/drawable/ring_pulse.xml new file mode 100644 index 0000000..11f3c14 --- /dev/null +++ b/app/src/main/res/drawable/ring_pulse.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..a8ac848 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_clone.xml b/app/src/main/res/layout/fragment_clone.xml new file mode 100644 index 0000000..1c9a16e --- /dev/null +++ b/app/src/main/res/layout/fragment_clone.xml @@ -0,0 +1,241 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_hce.xml b/app/src/main/res/layout/fragment_hce.xml new file mode 100644 index 0000000..6dc0204 --- /dev/null +++ b/app/src/main/res/layout/fragment_hce.xml @@ -0,0 +1,230 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_read.xml b/app/src/main/res/layout/fragment_read.xml new file mode 100644 index 0000000..a1bba62 --- /dev/null +++ b/app/src/main/res/layout/fragment_read.xml @@ -0,0 +1,291 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_write.xml b/app/src/main/res/layout/fragment_write.xml new file mode 100644 index 0000000..e9fdf44 --- /dev/null +++ b/app/src/main/res/layout/fragment_write.xml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..94d5338 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..94d5338 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..8368530 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..8368530 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..8368530 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..8368530 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..8368530 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8368530 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..8368530 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8368530 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..8368530 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8368530 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..9fef3f5 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,38 @@ + + + + #08091A + #0D1128 + #111827 + #162035 + #1E2D40 + + + #00D1FF + #3300D1FF + #1A00D1FF + #7C3AED + #337C3AED + + + #E2E8F0 + #64748B + #94A3B8 + + + #10B981 + #3310B981 + #EF4444 + #33EF4444 + #F59E0B + #33F59E0B + + + #050812 + #00D1FF + + + #FFFFFFFF + #FF000000 + #10B981 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..5261c85 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,65 @@ + + JRZ NFC Cloner + JRZ + NFC CLONER + Lesen + Schreiben + Klonen + HCE + NFC HCE Emulation + NFC Tag Emulation + + + Halte einen NFC-Tag ans Telefon + UID + Tag-Typ + Technologien + NDEF-Inhalt + Rohdaten (Seiten) + Tag-Dump speichern + UID kopieren + + + NDEF-Nachricht eingeben, dann Tag halten + Text (de) + URL + Roh-Hex + Inhalt eingeben… + Schreiben starten + Abbrechen + Warte auf Tag… + Erfolgreich geschrieben! + Tag ist schreibgeschützt + Inhalt zu groß für diesen Tag + + + Schritt 1: Quell-Tag lesen + Schritt 2: Ziel-Tag beschreiben + Quelle scannen + Auf Ziel schreiben + Noch kein Quell-Tag gelesen + Quelle bereit – %1$d Bytes + Halte Quell-Tag ans Telefon… + Halte Ziel-Tag ans Telefon… + Klon erfolgreich! + + + Emuliert einen NDEF-Tag via HCE.\nFunktioniert nur bei Systemen die AIDs prüfen, NICHT bei UID-basierter Prüfung. + AID (Hex, z.B. D2760000850101) + NDEF-Payload (Text oder Hex) + HCE aktivieren + HCE deaktivieren + HCE aktiv + HCE inaktiv + APDU-Log + Hinweis: UID-basierte Zugangssysteme können nicht per HCE emuliert werden. + + + NFC wird auf diesem Gerät nicht unterstützt + NFC ist deaktiviert. Bitte in den Einstellungen aktivieren. + Verbindung zum Tag fehlgeschlagen + Lesefehler: %1$s + Schreibfehler: %1$s + UID in Zwischenablage kopiert + Dump gespeichert: %1$s + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..308c4be --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/apduservice.xml b/app/src/main/res/xml/apduservice.xml new file mode 100644 index 0000000..efb11e5 --- /dev/null +++ b/app/src/main/res/xml/apduservice.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/app/src/main/res/xml/nfc_tech_filter.xml b/app/src/main/res/xml/nfc_tech_filter.xml new file mode 100644 index 0000000..1d2a30b --- /dev/null +++ b/app/src/main/res/xml/nfc_tech_filter.xml @@ -0,0 +1,30 @@ + + + + android.nfc.tech.Ndef + + + android.nfc.tech.NdefFormatable + + + android.nfc.tech.MifareUltralight + + + android.nfc.tech.MifareClassic + + + android.nfc.tech.NfcA + + + android.nfc.tech.NfcB + + + android.nfc.tech.NfcF + + + android.nfc.tech.NfcV + + + android.nfc.tech.IsoDep + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..7629126 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..df7e452 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f1fdcdf --- /dev/null +++ b/settings.gradle.kts @@ -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")