feat(contacts): обмен контактами NFC→BLE
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
@@ -17,6 +18,9 @@
|
||||
<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"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
|
||||
<application
|
||||
android:label="Komet"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package ru.komet.app
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattServer
|
||||
import android.bluetooth.BluetoothGattServerCallback
|
||||
import android.bluetooth.BluetoothGattService
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.le.AdvertiseCallback
|
||||
import android.bluetooth.le.AdvertiseData
|
||||
import android.bluetooth.le.AdvertiseSettings
|
||||
import android.bluetooth.le.BluetoothLeAdvertiser
|
||||
import android.bluetooth.le.BluetoothLeScanner
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import java.util.UUID
|
||||
|
||||
class BleContactExchange(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
const val LOG_TAG = "BleExchange"
|
||||
val SERVICE_UUID: UUID = UUID.fromString("f04b4f4d-4554-3100-0000-000000000001")
|
||||
val CHAR_UUID: UUID = UUID.fromString("f04b4f4d-4554-3100-0000-000000000002")
|
||||
const val MFG_ID = 0x4B4D
|
||||
}
|
||||
|
||||
var onReceived: ((Long) -> Unit)? = null
|
||||
var onError: ((String) -> Unit)? = null
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
private val manager: BluetoothManager? =
|
||||
context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
|
||||
private val adapter: BluetoothAdapter? = manager?.adapter
|
||||
|
||||
private var gattServer: BluetoothGattServer? = null
|
||||
private var advertiser: BluetoothLeAdvertiser? = null
|
||||
private var scanner: BluetoothLeScanner? = null
|
||||
private var scanCallback: ScanCallback? = null
|
||||
private var advertiseCallback: AdvertiseCallback? = null
|
||||
private var clientGatt: BluetoothGatt? = null
|
||||
|
||||
@Volatile private var selfId: Long = 0L
|
||||
@Volatile private var selfSession: String = ""
|
||||
@Volatile private var connecting = false
|
||||
@Volatile private var running = false
|
||||
|
||||
fun start(selfId: Long, selfSession: String) {
|
||||
val adapter = this.adapter
|
||||
if (adapter == null || !adapter.isEnabled) {
|
||||
emitError("bluetooth_off")
|
||||
return
|
||||
}
|
||||
this.selfId = selfId
|
||||
this.selfSession = selfSession
|
||||
running = true
|
||||
startGattServer()
|
||||
}
|
||||
|
||||
fun connectTo(peerSession: String) {
|
||||
if (!running || connecting) return
|
||||
startScan(peerSession)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
connecting = false
|
||||
stopScan()
|
||||
stopAdvertising()
|
||||
try {
|
||||
clientGatt?.disconnect()
|
||||
clientGatt?.close()
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "client close: ${e.message}")
|
||||
}
|
||||
clientGatt = null
|
||||
try {
|
||||
gattServer?.close()
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "server close: ${e.message}")
|
||||
}
|
||||
gattServer = null
|
||||
}
|
||||
|
||||
private fun startGattServer() {
|
||||
val server = try {
|
||||
manager?.openGattServer(context, serverCallback)
|
||||
} catch (e: SecurityException) {
|
||||
emitError("permission")
|
||||
return
|
||||
}
|
||||
if (server == null) {
|
||||
emitError("gatt_unavailable")
|
||||
return
|
||||
}
|
||||
gattServer = server
|
||||
val characteristic = BluetoothGattCharacteristic(
|
||||
CHAR_UUID,
|
||||
BluetoothGattCharacteristic.PROPERTY_WRITE or
|
||||
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE,
|
||||
BluetoothGattCharacteristic.PERMISSION_WRITE,
|
||||
)
|
||||
val service = BluetoothGattService(
|
||||
SERVICE_UUID,
|
||||
BluetoothGattService.SERVICE_TYPE_PRIMARY,
|
||||
)
|
||||
service.addCharacteristic(characteristic)
|
||||
try {
|
||||
server.addService(service)
|
||||
} catch (e: SecurityException) {
|
||||
emitError("permission")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAdvertising() {
|
||||
val advertiser = adapter?.bluetoothLeAdvertiser
|
||||
if (advertiser == null) {
|
||||
Log.w(LOG_TAG, "advertising unsupported on this device")
|
||||
return
|
||||
}
|
||||
this.advertiser = advertiser
|
||||
val settings = AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
||||
.setConnectable(true)
|
||||
.build()
|
||||
val data = AdvertiseData.Builder()
|
||||
.setIncludeDeviceName(false)
|
||||
.setIncludeTxPowerLevel(false)
|
||||
.addServiceUuid(ParcelUuid(SERVICE_UUID))
|
||||
.addManufacturerData(MFG_ID, hexToBytes(selfSession))
|
||||
.build()
|
||||
val callback = object : AdvertiseCallback() {
|
||||
override fun onStartFailure(errorCode: Int) {
|
||||
Log.w(LOG_TAG, "advertise failed: $errorCode")
|
||||
}
|
||||
}
|
||||
advertiseCallback = callback
|
||||
try {
|
||||
advertiser.startAdvertising(settings, data, callback)
|
||||
} catch (e: SecurityException) {
|
||||
emitError("permission")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAdvertising() {
|
||||
val callback = advertiseCallback ?: return
|
||||
try {
|
||||
advertiser?.stopAdvertising(callback)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "stopAdvertising: ${e.message}")
|
||||
}
|
||||
advertiseCallback = null
|
||||
}
|
||||
|
||||
private fun startScan(peerSession: String) {
|
||||
val scanner = adapter?.bluetoothLeScanner
|
||||
if (scanner == null) {
|
||||
emitError("scan_unavailable")
|
||||
return
|
||||
}
|
||||
this.scanner = scanner
|
||||
val target = peerSession.lowercase()
|
||||
val filters = listOf(
|
||||
ScanFilter.Builder()
|
||||
.setServiceUuid(ParcelUuid(SERVICE_UUID))
|
||||
.build(),
|
||||
)
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
val callback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult?) {
|
||||
handleScanResult(result, target)
|
||||
}
|
||||
|
||||
override fun onBatchScanResults(results: MutableList<ScanResult>?) {
|
||||
results?.forEach { handleScanResult(it, target) }
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.w(LOG_TAG, "scan failed: $errorCode")
|
||||
emitError("scan_failed")
|
||||
}
|
||||
}
|
||||
scanCallback = callback
|
||||
try {
|
||||
scanner.startScan(filters, settings, callback)
|
||||
} catch (e: SecurityException) {
|
||||
emitError("permission")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopScan() {
|
||||
val callback = scanCallback ?: return
|
||||
try {
|
||||
scanner?.stopScan(callback)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "stopScan: ${e.message}")
|
||||
}
|
||||
scanCallback = null
|
||||
}
|
||||
|
||||
private fun handleScanResult(result: ScanResult?, target: String) {
|
||||
if (result == null || connecting) return
|
||||
val mfg = result.scanRecord?.getManufacturerSpecificData(MFG_ID) ?: return
|
||||
if (bytesToHex(mfg).lowercase() != target) return
|
||||
connecting = true
|
||||
stopScan()
|
||||
connectGatt(result.device)
|
||||
}
|
||||
|
||||
private fun connectGatt(device: BluetoothDevice) {
|
||||
try {
|
||||
clientGatt = device.connectGatt(
|
||||
context,
|
||||
false,
|
||||
clientCallback,
|
||||
BluetoothDevice.TRANSPORT_LE,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
connecting = false
|
||||
emitError("permission")
|
||||
}
|
||||
}
|
||||
|
||||
private val serverCallback = object : BluetoothGattServerCallback() {
|
||||
override fun onServiceAdded(status: Int, service: BluetoothGattService?) {
|
||||
if (running) startAdvertising()
|
||||
}
|
||||
|
||||
override fun onCharacteristicWriteRequest(
|
||||
device: BluetoothDevice?,
|
||||
requestId: Int,
|
||||
characteristic: BluetoothGattCharacteristic?,
|
||||
preparedWrite: Boolean,
|
||||
responseNeeded: Boolean,
|
||||
offset: Int,
|
||||
value: ByteArray?,
|
||||
) {
|
||||
if (responseNeeded) {
|
||||
try {
|
||||
gattServer?.sendResponse(
|
||||
device,
|
||||
requestId,
|
||||
BluetoothGatt.GATT_SUCCESS,
|
||||
offset,
|
||||
null,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(LOG_TAG, "sendResponse: ${e.message}")
|
||||
}
|
||||
}
|
||||
if (characteristic?.uuid != CHAR_UUID || value == null) return
|
||||
val peerId = String(value, Charsets.UTF_8).trim().toLongOrNull() ?: return
|
||||
if (peerId > 0L) emitReceived(peerId)
|
||||
}
|
||||
}
|
||||
|
||||
private val clientCallback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
try {
|
||||
gatt?.discoverServices()
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(LOG_TAG, "discoverServices: ${e.message}")
|
||||
}
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
try {
|
||||
gatt?.close()
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "gatt close: ${e.message}")
|
||||
}
|
||||
if (gatt == clientGatt) clientGatt = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
|
||||
if (gatt == null || status != BluetoothGatt.GATT_SUCCESS) {
|
||||
gatt?.disconnect()
|
||||
return
|
||||
}
|
||||
val characteristic = gatt.getService(SERVICE_UUID)?.getCharacteristic(CHAR_UUID)
|
||||
if (characteristic == null) {
|
||||
gatt.disconnect()
|
||||
return
|
||||
}
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.value = selfId.toString().toByteArray(Charsets.UTF_8)
|
||||
try {
|
||||
@Suppress("DEPRECATION")
|
||||
gatt.writeCharacteristic(characteristic)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(LOG_TAG, "writeCharacteristic: ${e.message}")
|
||||
gatt.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
gatt: BluetoothGatt?,
|
||||
characteristic: BluetoothGattCharacteristic?,
|
||||
status: Int,
|
||||
) {
|
||||
gatt?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitReceived(id: Long) {
|
||||
main.post { onReceived?.invoke(id) }
|
||||
}
|
||||
|
||||
private fun emitError(reason: String) {
|
||||
main.post { onError?.invoke(reason) }
|
||||
}
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray {
|
||||
val clean = if (hex.length % 2 == 0) hex else "0$hex"
|
||||
val out = ByteArray(clean.length / 2)
|
||||
for (i in out.indices) {
|
||||
out[i] = clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun bytesToHex(bytes: ByteArray): String {
|
||||
val sb = StringBuilder(bytes.size * 2)
|
||||
for (b in bytes) sb.append("%02x".format(b))
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package ru.komet.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -15,6 +16,8 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
@@ -37,11 +40,16 @@ class MainActivity : FlutterActivity() {
|
||||
@Volatile private var nfcCycling = false
|
||||
private val nfcReaderCallback = NfcAdapter.ReaderCallback { tag -> onNfcTagDiscovered(tag) }
|
||||
|
||||
private var ble: BleContactExchange? = null
|
||||
private var pendingSelfId = 0L
|
||||
private var pendingSession = ""
|
||||
|
||||
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
|
||||
const val BLE_PERMS_REQUEST = 7711
|
||||
val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or
|
||||
NfcAdapter.FLAG_READER_NFC_B or
|
||||
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
||||
@@ -198,20 +206,96 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
|
||||
private fun startNfcExchange(selfId: Long) {
|
||||
val session = "%08x".format(nfcJitter.nextInt())
|
||||
NfcExchange.selfId = selfId
|
||||
NfcExchange.selfSession = session
|
||||
NfcExchange.active = true
|
||||
NfcExchange.onServed = { onNfcServed() }
|
||||
seenPeers.clear()
|
||||
pendingSelfId = selfId
|
||||
pendingSession = session
|
||||
nfcCycling = true
|
||||
nfcHandler.removeCallbacksAndMessages(null)
|
||||
nfcReaderOn()
|
||||
ensureBleStarted()
|
||||
}
|
||||
|
||||
private fun stopNfcExchange() {
|
||||
nfcCycling = false
|
||||
NfcExchange.active = false
|
||||
NfcExchange.selfId = 0L
|
||||
NfcExchange.selfSession = ""
|
||||
NfcExchange.onServed = null
|
||||
nfcHandler.removeCallbacksAndMessages(null)
|
||||
nfcReaderDisable()
|
||||
ble?.stop()
|
||||
}
|
||||
|
||||
private fun blePermissions(): Array<String> =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_ADVERTISE,
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
|
||||
private fun hasBlePermissions(): Boolean = blePermissions().all {
|
||||
ContextCompat.checkSelfPermission(this, it) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun ensureBleStarted() {
|
||||
if (hasBlePermissions()) {
|
||||
startBle()
|
||||
} else {
|
||||
ActivityCompat.requestPermissions(this, blePermissions(), BLE_PERMS_REQUEST)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startBle() {
|
||||
val exchange = ble ?: BleContactExchange(applicationContext).also {
|
||||
it.onReceived = { id -> onBleReceived(id) }
|
||||
it.onError = { reason -> onBleError(reason) }
|
||||
ble = it
|
||||
}
|
||||
exchange.start(pendingSelfId, pendingSession)
|
||||
}
|
||||
|
||||
private fun onBleReceived(id: Long) {
|
||||
if (id == NfcExchange.selfId || !seenPeers.add(id)) return
|
||||
nfcEvents?.success(mapOf("event" to "received", "id" to id))
|
||||
}
|
||||
|
||||
private fun onBleError(reason: String) {
|
||||
nfcEvents?.success(mapOf("event" to "error", "reason" to reason))
|
||||
}
|
||||
|
||||
private fun onNfcServed() {
|
||||
nfcHandler.post {
|
||||
if (!NfcExchange.active) return@post
|
||||
nfcCycling = false
|
||||
nfcReaderDisable()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode != BLE_PERMS_REQUEST) return
|
||||
if (!NfcExchange.active) return
|
||||
val granted = grantResults.isNotEmpty() &&
|
||||
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
|
||||
if (granted) {
|
||||
startBle()
|
||||
} else {
|
||||
onBleError("permission")
|
||||
}
|
||||
}
|
||||
|
||||
private fun nfcReaderOn() {
|
||||
@@ -250,7 +334,7 @@ class MainActivity : FlutterActivity() {
|
||||
val isoDep = IsoDep.get(tag) ?: return
|
||||
val peer = try {
|
||||
isoDep.connect()
|
||||
NfcExchange.parsePeerId(isoDep.transceive(NfcExchange.buildSelectCommand()))
|
||||
NfcExchange.parsePeer(isoDep.transceive(NfcExchange.buildSelectCommand()))
|
||||
} catch (e: Exception) {
|
||||
Log.w(NFC_TAG, "transceive failed: ${e.message}")
|
||||
null
|
||||
@@ -260,12 +344,15 @@ class MainActivity : FlutterActivity() {
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
if (peer == null || peer <= 0L) return
|
||||
if (peer == null || peer.id <= 0L) return
|
||||
nfcHandler.post {
|
||||
if (peer == NfcExchange.selfId || !seenPeers.add(peer)) return@post
|
||||
if (peer.id == NfcExchange.selfId) return@post
|
||||
nfcCycling = false
|
||||
nfcReaderDisable()
|
||||
nfcEvents?.success(mapOf("event" to "received", "id" to peer))
|
||||
ble?.connectTo(peer.session)
|
||||
if (seenPeers.add(peer.id)) {
|
||||
nfcEvents?.success(mapOf("event" to "received", "id" to peer.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,17 +4,24 @@ object NfcExchange {
|
||||
|
||||
const val AID = "F04B4F4D455431"
|
||||
|
||||
private const val PREFIX = "KMT1:"
|
||||
private const val PREFIX = "KMT2:"
|
||||
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
|
||||
@Volatile var selfSession: String = ""
|
||||
|
||||
@Volatile var onServed: (() -> Unit)? = null
|
||||
|
||||
data class Peer(val id: Long, val session: String)
|
||||
|
||||
fun buildSelectResponse(): ByteArray {
|
||||
val id = selfId
|
||||
if (!active || id <= 0L) return STATUS_NOT_FOUND
|
||||
return (PREFIX + id).toByteArray(Charsets.UTF_8) + STATUS_OK
|
||||
val session = selfSession
|
||||
if (!active || id <= 0L || session.isEmpty()) return STATUS_NOT_FOUND
|
||||
onServed?.invoke()
|
||||
return (PREFIX + id + ":" + session).toByteArray(Charsets.UTF_8) + STATUS_OK
|
||||
}
|
||||
|
||||
fun buildSelectCommand(): ByteArray {
|
||||
@@ -23,14 +30,19 @@ object NfcExchange {
|
||||
aid + byteArrayOf(0x00)
|
||||
}
|
||||
|
||||
fun parsePeerId(response: ByteArray?): Long? {
|
||||
fun parsePeer(response: ByteArray?): Peer? {
|
||||
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()
|
||||
val parts = text.substring(PREFIX.length).split(":")
|
||||
if (parts.size < 2) return null
|
||||
val id = parts[0].toLongOrNull() ?: return null
|
||||
val session = parts[1]
|
||||
if (session.isEmpty()) return null
|
||||
return Peer(id, session)
|
||||
}
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray {
|
||||
|
||||
@@ -3,13 +3,14 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
enum NfcEventType { received, cancelled }
|
||||
enum NfcEventType { received, cancelled, error }
|
||||
|
||||
class NfcEvent {
|
||||
final NfcEventType type;
|
||||
final int? id;
|
||||
final String? reason;
|
||||
|
||||
const NfcEvent(this.type, this.id);
|
||||
const NfcEvent(this.type, this.id, {this.reason});
|
||||
}
|
||||
|
||||
class NfcStatus {
|
||||
@@ -59,9 +60,14 @@ class NfcExchangeService {
|
||||
NfcEvent _decodeEvent(dynamic raw) {
|
||||
final map = raw is Map ? raw : const {};
|
||||
final id = map['id'];
|
||||
final type = map['event'] == 'received'
|
||||
? NfcEventType.received
|
||||
: NfcEventType.cancelled;
|
||||
return NfcEvent(type, id is int ? id : (id is num ? id.toInt() : null));
|
||||
final parsedId = id is int ? id : (id is num ? id.toInt() : null);
|
||||
switch (map['event']) {
|
||||
case 'received':
|
||||
return NfcEvent(NfcEventType.received, parsedId);
|
||||
case 'error':
|
||||
return NfcEvent(NfcEventType.error, null, reason: map['reason'] as String?);
|
||||
default:
|
||||
return const NfcEvent(NfcEventType.cancelled, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
|
||||
enum _Stage { checking, unsupported, disabled, scanning, found, adding, added }
|
||||
enum _Stage { checking, unsupported, disabled, failed, scanning, found, adding, added }
|
||||
|
||||
class NfcExchangeSheet extends StatefulWidget {
|
||||
const NfcExchangeSheet({super.key});
|
||||
@@ -30,6 +30,7 @@ class _NfcExchangeSheetState extends State<NfcExchangeSheet>
|
||||
_Stage _stage = _Stage.checking;
|
||||
int? _peerId;
|
||||
Map<String, dynamic>? _peerInfo;
|
||||
String _failReason = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -78,6 +79,15 @@ class _NfcExchangeSheetState extends State<NfcExchangeSheet>
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type == NfcEventType.error) {
|
||||
if (mounted && _peerId == null) {
|
||||
setState(() {
|
||||
_failReason = _reasonText(event.reason);
|
||||
_stage = _Stage.failed;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
final id = event.id;
|
||||
if (id == null || _peerId != null) return;
|
||||
_peerId = id;
|
||||
@@ -141,6 +151,17 @@ class _NfcExchangeSheetState extends State<NfcExchangeSheet>
|
||||
}
|
||||
}
|
||||
|
||||
String _reasonText(String? reason) {
|
||||
switch (reason) {
|
||||
case 'bluetooth_off':
|
||||
return 'Включите Bluetooth и попробуйте снова';
|
||||
case 'permission':
|
||||
return 'Нужны разрешения Bluetooth для обмена';
|
||||
default:
|
||||
return 'Не удалось установить соединение';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
@@ -191,6 +212,8 @@ class _NfcExchangeSheetState extends State<NfcExchangeSheet>
|
||||
Symbols.nfc,
|
||||
'Включите NFC в настройках телефона и попробуйте снова',
|
||||
);
|
||||
case _Stage.failed:
|
||||
return _message(cs, Symbols.bluetooth_disabled, _failReason);
|
||||
case _Stage.scanning:
|
||||
return _scanning(cs);
|
||||
case _Stage.found:
|
||||
|
||||
Reference in New Issue
Block a user