feat(contacts): добавление контакта через NFC
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user