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: