diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7396bc8..cc7840c 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
+
@@ -17,6 +18,9 @@
+
+
+
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?) {
+ 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()
+ }
+}
diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt
index b9e3d56..7cc3421 100644
--- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt
+++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt
@@ -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 =
+ 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,
+ 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))
+ }
}
}
diff --git a/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt b/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt
index 6146c3e..9bf557f 100644
--- a/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt
+++ b/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt
@@ -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 {
diff --git a/lib/core/nfc/nfc_exchange_service.dart b/lib/core/nfc/nfc_exchange_service.dart
index a5b802d..f5af0e9 100644
--- a/lib/core/nfc/nfc_exchange_service.dart
+++ b/lib/core/nfc/nfc_exchange_service.dart
@@ -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);
+ }
}
}
diff --git a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart
index 1b13249..529c03c 100644
--- a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart
+++ b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart
@@ -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
_Stage _stage = _Stage.checking;
int? _peerId;
Map? _peerInfo;
+ String _failReason = '';
@override
void initState() {
@@ -78,6 +79,15 @@ class _NfcExchangeSheetState extends State
}
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
}
}
+ 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
Symbols.nfc,
'Включите NFC в настройках телефона и попробуйте снова',
);
+ case _Stage.failed:
+ return _message(cs, Symbols.bluetooth_disabled, _failReason);
case _Stage.scanning:
return _scanning(cs);
case _Stage.found: