// MobNfcBridge.kt — plugin-owned Kotlin bridge class for mob_nfc. // // Mirrors mob_bluetooth's bridge. Re-homed to the plugin's OWN package // `io.mob.nfc` so the JNI thunk symbol names // (`Java_io_mob_nfc_MobNfcBridge_*`) are package-stable and shippable (they // live in the sibling mob_nfc_jni.c). // // Registration: mob_dev copies this file into the app Kotlin sourceSet and // generates `MobPluginBootstrap.registerAll(activity)` (called from // MainActivity.onCreate) which invokes `register()` then `setActivity()`. // `register()` calls the `nativeRegister` thunk (zig NIF), which caches THIS // class's jclass + nfc_* method ids. The NIF's outbound CallStaticVoidMethod // uses that cache; the `nativeDeliverNfc*` externs resolve to mob_nfc_jni.c. // // NFC reading uses NfcAdapter reader mode (enableReaderMode) — foreground // dispatch while the activity is resumed. The reader callback fires on a binder // thread; delivery `nativeDeliverNfc*` → enif_send is thread-safe. package io.mob.nfc import android.app.Activity import android.nfc.NdefMessage import android.nfc.NfcAdapter import android.nfc.Tag import android.nfc.cardemulation.HostApduService import android.nfc.tech.Ndef import android.nfc.tech.NdefFormatable import android.os.Bundle import java.lang.ref.WeakReference object MobNfcBridge : io.mob.plugin.MobActivityAware { // ── Bridge-class registration (caches this jclass + nfc_* method ids) ──── @JvmStatic external fun nativeRegister() @JvmStatic fun register() { nativeRegister() } private var activityRef: WeakReference? = null // Not @JvmStatic: overrides MobActivityAware.setActivity (illegal to be // @JvmStatic on an interface override in an object). Called via instance // dispatch from the generated bootstrap. override fun setActivity(activity: Activity) { activityRef = WeakReference(activity) } private fun activity(): Activity? = activityRef?.get() // One reader session at a time in this cut. @Volatile private var adapter: NfcAdapter? = null // Set while a write session is armed; the next tapped tag is written, not read. @Volatile private var pendingWrite: ByteArray? = null // HCE state (read by MobNfcApduService, which the OS instantiates separately). // `emulatedNdef` is the raw NDEF message the emulated tag serves; null = not // emulating. `emulationPid` is the BEAM process to notify on a reader read. // `emulationWritable` advertises the emulated tag as writable so a reader // (e.g. another phone's write_ndef) can UPDATE BINARY into it. @Volatile @JvmStatic var emulatedNdef: ByteArray? = null @Volatile @JvmStatic var emulationWritable: Boolean = false @Volatile private var emulationPid: Long = 0 // ── Static methods the NIF calls (signatures cached by nativeRegister) ─── /** True when the device has an NFC radio present and enabled. */ @JvmStatic fun nfc_available(): Boolean { val act = activity() ?: return false val a = NfcAdapter.getDefaultAdapter(act) ?: return false return a.isEnabled } /** Start reader mode; NDEF/tag events flow back to `pid`. */ @JvmStatic fun nfc_start_reading(pid: Long, optsJson: String?) { val act = activity() ?: run { nativeDeliverNfcError(pid, "no_activity") return } val a = NfcAdapter.getDefaultAdapter(act) if (a == null) { nativeDeliverNfcError(pid, "unavailable") return } if (!a.isEnabled) { nativeDeliverNfcError(pid, "disabled") return } adapter = a val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V or NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS act.runOnUiThread { try { a.enableReaderMode(act, { tag -> onTag(pid, tag) }, flags, null) nativeDeliverNfcSessionStarted(pid) } catch (_: Throwable) { nativeDeliverNfcError(pid, "start_failed") } } } /** Arm a write session; the next tapped tag gets `optsJson.ndef` (base64). */ @JvmStatic fun nfc_start_writing(pid: Long, optsJson: String?) { val bytes = try { val obj = org.json.JSONObject(optsJson ?: "{}") android.util.Base64.decode(obj.optString("ndef", ""), android.util.Base64.DEFAULT) } catch (_: Throwable) { nativeDeliverNfcError(pid, "write_failed") return } val act = activity() ?: run { nativeDeliverNfcError(pid, "no_activity") return } val a = NfcAdapter.getDefaultAdapter(act) if (a == null) { nativeDeliverNfcError(pid, "unavailable") return } if (!a.isEnabled) { nativeDeliverNfcError(pid, "disabled") return } adapter = a pendingWrite = bytes val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V or NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS act.runOnUiThread { try { a.enableReaderMode(act, { tag -> onTag(pid, tag) }, flags, null) nativeDeliverNfcSessionStarted(pid) } catch (_: Throwable) { nativeDeliverNfcError(pid, "start_failed") } } } /** Stop the reader session started by `pid`. */ @JvmStatic fun nfc_stop_reading(pid: Long) { pendingWrite = null val act = activity() val a = adapter if (act != null && a != null) { act.runOnUiThread { try { a.disableReaderMode(act) } catch (_: Throwable) {} nativeDeliverNfcSessionEnded(pid, "done") } } else { nativeDeliverNfcSessionEnded(pid, "done") } } /** Begin emulating an NDEF tag serving `optsJson.ndef` (base64). */ @JvmStatic fun nfc_emulate_ndef(pid: Long, optsJson: String?) { val obj = try { org.json.JSONObject(optsJson ?: "{}") } catch (_: Throwable) { nativeDeliverNfcError(pid, "bad_payload") return } val bytes = try { android.util.Base64.decode(obj.optString("ndef", ""), android.util.Base64.DEFAULT) } catch (_: Throwable) { nativeDeliverNfcError(pid, "bad_payload") return } // Reader mode and card emulation are mutually exclusive on one NFC // controller — if a reader session is active (e.g. auto-armed on mount), // drop it so the phone presents purely as an emulated card. val act = activity() val a = adapter if (act != null && a != null) { act.runOnUiThread { try { a.disableReaderMode(act) } catch (_: Throwable) {} } } emulatedNdef = bytes emulationWritable = obj.optBoolean("writable", false) emulationPid = pid nativeDeliverNfcEmulationStarted(pid) } /** Stop emulating. */ @JvmStatic fun nfc_stop_emulation(pid: Long) { emulatedNdef = null emulationWritable = false emulationPid = 0 nativeDeliverNfcEmulationStopped(pid) } // Called by MobNfcApduService when a reader has read the emulated NDEF file. @JvmStatic fun onHceRead() { val pid = emulationPid if (pid != 0L) nativeDeliverNfcHceRead(pid) } // Called by MobNfcApduService when a reader has WRITTEN a new NDEF message // into the emulated (writable) tag. Updates what we now serve + notifies. @JvmStatic fun onHceWritten(bytes: ByteArray) { emulatedNdef = bytes val pid = emulationPid if (pid != 0L) nativeDeliverNfcHceWritten(pid, bytes) } // Reader callback (binder thread): write if a write session is armed, // otherwise read NDEF if present, else report the tag. private fun onTag(pid: Long, tag: Tag) { val toWrite = pendingWrite if (toWrite != null) { pendingWrite = null writeTag(pid, tag, toWrite) return } val tagId = tag.id?.joinToString("") { "%02x".format(it.toInt() and 0xFF) } ?: "" val ndef = Ndef.get(tag) if (ndef == null) { val tech = tag.techList?.joinToString(",") ?: "" nativeDeliverNfcTag(pid, tagId, tech) return } try { ndef.connect() val msg = ndef.ndefMessage ?: ndef.cachedNdefMessage val bytes = msg?.toByteArray() ?: ByteArray(0) nativeDeliverNfcNdef(pid, tagId, bytes, ndef.isWritable, ndef.maxSize) } catch (_: Throwable) { nativeDeliverNfcError(pid, "read_failed") } finally { try { ndef.close() } catch (_: Throwable) {} } } // Write path (binder thread): NDEF-formatted tags via Ndef, blank tags via // NdefFormatable.format. Reports :read_only / :too_small / :not_ndef / // :write_failed, or :written on success. private fun writeTag(pid: Long, tag: Tag, bytes: ByteArray) { val msg = try { NdefMessage(bytes) } catch (_: Throwable) { nativeDeliverNfcError(pid, "write_failed") return } val ndef = Ndef.get(tag) if (ndef != null) { try { ndef.connect() if (!ndef.isWritable) { nativeDeliverNfcError(pid, "read_only") return } if (ndef.maxSize < bytes.size) { nativeDeliverNfcError(pid, "too_small") return } ndef.writeNdefMessage(msg) nativeDeliverNfcWritten(pid, bytes.size) } catch (_: Throwable) { nativeDeliverNfcError(pid, "write_failed") } finally { try { ndef.close() } catch (_: Throwable) {} } return } // Not yet NDEF-formatted: format-and-write in one shot if the tag supports it. val formatable = NdefFormatable.get(tag) if (formatable != null) { try { formatable.connect() formatable.format(msg) nativeDeliverNfcWritten(pid, bytes.size) } catch (_: Throwable) { nativeDeliverNfcError(pid, "write_failed") } finally { try { formatable.close() } catch (_: Throwable) {} } return } nativeDeliverNfcError(pid, "not_ndef") } // ── delivery externs (resolve to mob_nfc_jni.c thunks) ─────────────────── @JvmStatic external fun nativeDeliverNfcSessionStarted(pid: Long) @JvmStatic external fun nativeDeliverNfcNdef( pid: Long, tagId: String, ndef: ByteArray, writable: Boolean, maxSize: Int ) @JvmStatic external fun nativeDeliverNfcTag(pid: Long, tagId: String, tech: String) @JvmStatic external fun nativeDeliverNfcWritten(pid: Long, bytes: Int) @JvmStatic external fun nativeDeliverNfcSessionEnded(pid: Long, reason: String) @JvmStatic external fun nativeDeliverNfcError(pid: Long, reason: String) @JvmStatic external fun nativeDeliverNfcEmulationStarted(pid: Long) @JvmStatic external fun nativeDeliverNfcEmulationStopped(pid: Long) @JvmStatic external fun nativeDeliverNfcHceRead(pid: Long) @JvmStatic external fun nativeDeliverNfcHceWritten(pid: Long, ndef: ByteArray) } // ── Host Card Emulation service (OS-instantiated, NOT via the bootstrap) ────── // // Rides in this same .kt file so mob_dev's single-`bridge_kt` copy delivers it. // The OS creates it from the AndroidManifest declaration (see the // plugin host_requirements); it serves the NFC Forum Type-4 Tag command set // (SELECT AID / SELECT CC / SELECT NDEF / READ BINARY / UPDATE BINARY) over the // NDEF app AID D2760000850101, presenting MobNfcBridge.emulatedNdef as a tag. // // This is a faithful mirror of the pure `MobNfc.Hce` Elixir module // (lib/mob_nfc/hce.ex), which is the TESTED reference for this state machine // (test/hce_test.exs) — a HostApduService must answer readers even when the // BEAM isn't running, so it can't delegate there at runtime. Keep the two in // sync: same CC bytes, same SELECT/READ/UPDATE handling, same completion rule. class MobNfcApduService : HostApduService() { // 0 = none selected, 1 = Capability Container (E103), 2 = NDEF file (E104). private var selectedFile = 0 // When emulating a WRITABLE tag, a reader's UPDATE BINARY commands accumulate // here (the NDEF file image: [NLEN hi][NLEN lo][message…]). Null until the // first write; reset after a completed write / on deactivation. private var writeBuf: ByteArray? = null override fun processCommandApdu(apdu: ByteArray?, extras: Bundle?): ByteArray { if (apdu == null || apdu.size < 4) return SW_ERROR val ins = apdu[1].toInt() and 0xFF // SELECT (00 A4 ...) if (apdu[0].toInt() and 0xFF == 0x00 && ins == 0xA4) { val p1 = apdu[2].toInt() and 0xFF return when (p1) { // SELECT by name (AID) — the NDEF Tag Application. 0x04 -> { selectedFile = 0 SW_OK } // SELECT by file id (P1=00, P2=0C, Lc=02, file id follows). 0x00 -> { if (apdu.size < 7) return SW_ERROR val fid = ((apdu[5].toInt() and 0xFF) shl 8) or (apdu[6].toInt() and 0xFF) selectedFile = when (fid) { 0xE103 -> 1 0xE104 -> 2 else -> 0 } if (selectedFile != 0) SW_OK else SW_FILE_NOT_FOUND } else -> SW_ERROR } } // READ BINARY (00 B0 ) if (apdu[0].toInt() and 0xFF == 0x00 && ins == 0xB0) { val offset = ((apdu[2].toInt() and 0xFF) shl 8) or (apdu[3].toInt() and 0xFF) val le = if (apdu.size >= 5) apdu[4].toInt() and 0xFF else 0 val file = when (selectedFile) { 1 -> capabilityContainer() 2 -> ndefFile() else -> return SW_FILE_NOT_FOUND } if (offset > file.size) return SW_ERROR val end = minOf(offset + le, file.size) val slice = file.copyOfRange(offset, end) // Notify once the NDEF file has been read to its end. if (selectedFile == 2 && end >= file.size) MobNfcBridge.onHceRead() return slice + SW_OK } // UPDATE BINARY (00 D6 ) — a reader writing // into the emulated NDEF file. Only honoured for a writable emulation and // only against the NDEF file (E104). if (apdu[0].toInt() and 0xFF == 0x00 && ins == 0xD6) { if (!MobNfcBridge.emulationWritable || selectedFile != 2) return SW_FILE_NOT_FOUND if (apdu.size < 5) return SW_ERROR val offset = ((apdu[2].toInt() and 0xFF) shl 8) or (apdu[3].toInt() and 0xFF) val lc = apdu[4].toInt() and 0xFF if (apdu.size < 5 + lc) return SW_ERROR val buf = writeBuf ?: ByteArray(NDEF_CAPACITY).also { writeBuf = it } if (offset + lc > buf.size) return SW_ERROR System.arraycopy(apdu, 5, buf, offset, lc) // A non-zero NLEN at offset 0 means the message is fully written. val nlen = ((buf[0].toInt() and 0xFF) shl 8) or (buf[1].toInt() and 0xFF) if (nlen in 1..(buf.size - 2)) { val msg = buf.copyOfRange(2, 2 + nlen) writeBuf = null MobNfcBridge.onHceWritten(msg) } return SW_OK } return SW_INS_NOT_SUPPORTED } override fun onDeactivated(reason: Int) { selectedFile = 0 writeBuf = null } // NDEF file = 2-byte NLEN (message length) + the NDEF message. private fun ndefFile(): ByteArray { val msg = MobNfcBridge.emulatedNdef ?: ByteArray(0) val nlen = msg.size return byteArrayOf((nlen shr 8).toByte(), (nlen and 0xFF).toByte()) + msg } // Capability Container: CCLEN=000F, ver=2.0, MLe=00FB, MLc=00FF, then the // NDEF File Control TLV (T=04 L=06 fid=E104 maxsize=0400 read=00 write access). // Write access is 00 (writable) when emulating a writable tag, else FF (RO). private fun capabilityContainer(): ByteArray { val write = if (MobNfcBridge.emulationWritable) 0x00.toByte() else 0xFF.toByte() return byteArrayOf( 0x00, 0x0F, 0x20, 0x00, 0xFB.toByte(), 0x00, 0xFF.toByte(), 0x04, 0x06, 0xE1.toByte(), 0x04, 0x04, 0x00, 0x00, write) } companion object { // Max NDEF file size advertised in the CC (0x0400 = 1024, incl. 2-byte NLEN). private const val NDEF_CAPACITY = 1024 private val SW_OK = byteArrayOf(0x90.toByte(), 0x00) private val SW_FILE_NOT_FOUND = byteArrayOf(0x6A, 0x82.toByte()) private val SW_INS_NOT_SUPPORTED = byteArrayOf(0x6D, 0x00) private val SW_ERROR = byteArrayOf(0x6F, 0x00) } }