feat(contacts): обмен контактами NFC→BLE

This commit is contained in:
klockky
2026-06-21 10:18:48 +00:00
parent f20a818825
commit 6b082a81fe
6 changed files with 490 additions and 16 deletions
+4
View File
@@ -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 {
+12 -6
View File
@@ -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: