feat(contacts): добавление контакта через NFC

This commit is contained in:
klockky
2026-06-21 07:49:15 +00:00
parent d53f974ddb
commit 1299f1af2f
10 changed files with 722 additions and 1 deletions
+13
View File
@@ -15,6 +15,8 @@
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.NFC"/>
<uses-feature android:name="android.hardware.nfc.hce" android:required="false"/>
<application
android:label="Komet"
android:name="${applicationName}"
@@ -67,6 +69,17 @@
android:name=".UploadForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<service
android:name=".NfcHostApduService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/komet_nfc_apdu"/>
</service>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -8,15 +8,20 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import java.net.NetworkInterface
import java.util.Collections
import java.util.Random
import java.util.concurrent.atomic.AtomicBoolean
class MainActivity : FlutterActivity() {
@@ -24,8 +29,22 @@ class MainActivity : FlutterActivity() {
private val channelName = "ru.komet.app/vpn_bypass"
private val iconAliases = listOf("DefaultIcon", "MinimalIcon")
private var nfcAdapter: NfcAdapter? = null
private var nfcEvents: EventChannel.EventSink? = null
private val nfcHandler = Handler(Looper.getMainLooper())
private val nfcJitter = Random()
private val seenPeers = HashSet<Long>()
@Volatile private var nfcCycling = false
private val nfcReaderCallback = NfcAdapter.ReaderCallback { tag -> onNfcTagDiscovered(tag) }
private companion object {
const val LOG_TAG = "VpnBypass"
const val NFC_TAG = "NfcExchange"
const val NFC_PHASE_MIN_MS = 350L
const val NFC_PHASE_JITTER_MS = 400
val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
}
private fun applyIcon(name: String) {
@@ -50,6 +69,49 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"ru.komet.app/nfc",
).setMethodCallHandler { call, result ->
when (call.method) {
"status" -> result.success(nfcStatus())
"start" -> {
val selfId = when (val v = call.argument<Any>("selfId")) {
is Int -> v.toLong()
is Long -> v
else -> 0L
}
if (selfId <= 0L) {
result.error("INVALID_ID", "selfId must be positive", null)
} else {
startNfcExchange(selfId)
result.success(null)
}
}
"stop" -> {
stopNfcExchange()
result.success(null)
}
else -> result.notImplemented()
}
}
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
"ru.komet.app/nfc_events",
).setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
nfcEvents = events
}
override fun onCancel(arguments: Any?) {
nfcEvents = null
}
})
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
channelName,
@@ -127,6 +189,94 @@ class MainActivity : FlutterActivity() {
}
}
private fun nfcStatus(): Map<String, Any> {
val adapter = nfcAdapter
return mapOf(
"supported" to (adapter != null),
"enabled" to (adapter?.isEnabled == true),
)
}
private fun startNfcExchange(selfId: Long) {
NfcExchange.selfId = selfId
NfcExchange.active = true
seenPeers.clear()
nfcCycling = true
nfcHandler.removeCallbacksAndMessages(null)
nfcReaderOn()
}
private fun stopNfcExchange() {
nfcCycling = false
NfcExchange.active = false
NfcExchange.selfId = 0L
nfcHandler.removeCallbacksAndMessages(null)
nfcReaderDisable()
}
private fun nfcReaderOn() {
if (!nfcCycling) return
nfcReaderEnable()
nfcHandler.postDelayed({ nfcReaderOff() }, nfcPhaseDuration())
}
private fun nfcReaderOff() {
if (!nfcCycling) return
nfcReaderDisable()
nfcHandler.postDelayed({ nfcReaderOn() }, nfcPhaseDuration())
}
private fun nfcPhaseDuration(): Long =
NFC_PHASE_MIN_MS + nfcJitter.nextInt(NFC_PHASE_JITTER_MS)
private fun nfcReaderEnable() {
val adapter = nfcAdapter ?: return
try {
adapter.enableReaderMode(this, nfcReaderCallback, NFC_READER_FLAGS, null)
} catch (e: Exception) {
Log.w(NFC_TAG, "enableReaderMode failed: ${e.message}")
}
}
private fun nfcReaderDisable() {
try {
nfcAdapter?.disableReaderMode(this)
} catch (e: Exception) {
Log.w(NFC_TAG, "disableReaderMode failed: ${e.message}")
}
}
private fun onNfcTagDiscovered(tag: Tag) {
val isoDep = IsoDep.get(tag) ?: return
val peer = try {
isoDep.connect()
NfcExchange.parsePeerId(isoDep.transceive(NfcExchange.buildSelectCommand()))
} catch (e: Exception) {
Log.w(NFC_TAG, "transceive failed: ${e.message}")
null
} finally {
try {
isoDep.close()
} catch (_: Exception) {
}
}
if (peer == null || peer <= 0L) return
nfcHandler.post {
if (peer == NfcExchange.selfId || !seenPeers.add(peer)) return@post
nfcCycling = false
nfcReaderDisable()
nfcEvents?.success(mapOf("event" to "received", "id" to peer))
}
}
override fun onPause() {
super.onPause()
if (NfcExchange.active) {
stopNfcExchange()
nfcEvents?.success(mapOf("event" to "cancelled"))
}
}
private fun connectivityManager(): ConnectivityManager =
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -0,0 +1,43 @@
package ru.komet.app
object NfcExchange {
const val AID = "F04B4F4D455431"
private const val PREFIX = "KMT1:"
private val STATUS_OK = byteArrayOf(0x90.toByte(), 0x00)
private val STATUS_NOT_FOUND = byteArrayOf(0x6A, 0x82.toByte())
@Volatile var active: Boolean = false
@Volatile var selfId: Long = 0L
fun buildSelectResponse(): ByteArray {
val id = selfId
if (!active || id <= 0L) return STATUS_NOT_FOUND
return (PREFIX + id).toByteArray(Charsets.UTF_8) + STATUS_OK
}
fun buildSelectCommand(): ByteArray {
val aid = hexToBytes(AID)
return byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, aid.size.toByte()) +
aid + byteArrayOf(0x00)
}
fun parsePeerId(response: ByteArray?): Long? {
if (response == null || response.size < 2) return null
val sw1 = response[response.size - 2]
val sw2 = response[response.size - 1]
if (sw1 != 0x90.toByte() || sw2.toInt() != 0x00) return null
val text = String(response.copyOfRange(0, response.size - 2), Charsets.UTF_8)
if (!text.startsWith(PREFIX)) return null
return text.substring(PREFIX.length).toLongOrNull()
}
private fun hexToBytes(hex: String): ByteArray {
val out = ByteArray(hex.length / 2)
for (i in out.indices) {
out[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
return out
}
}
@@ -0,0 +1,25 @@
package ru.komet.app
import android.nfc.cardemulation.HostApduService
import android.os.Bundle
class NfcHostApduService : HostApduService() {
private val selectHeader = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00)
private val statusNotFound = byteArrayOf(0x6A, 0x82.toByte())
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
if (commandApdu == null || !isSelectApdu(commandApdu)) return statusNotFound
return NfcExchange.buildSelectResponse()
}
override fun onDeactivated(reason: Int) {}
private fun isSelectApdu(apdu: ByteArray): Boolean {
if (apdu.size < selectHeader.size) return false
for (i in selectHeader.indices) {
if (apdu[i] != selectHeader[i]) return false
}
return true
}
}
@@ -0,0 +1,4 @@
<resources>
<string name="nfc_service_description">Komet contact exchange</string>
<string name="nfc_aid_group_description">Komet contact exchange</string>
</resources>
@@ -0,0 +1,9 @@
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/nfc_service_description"
android:requireDeviceUnlock="false">
<aid-group
android:description="@string/nfc_aid_group_description"
android:category="other">
<aid-filter android:name="F04B4F4D455431"/>
</aid-group>
</host-apdu-service>