diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index f453656..cc7840c 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
+
@@ -15,6 +16,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/ru/komet/app/BleContactExchange.kt b/android/app/src/main/kotlin/ru/komet/app/BleContactExchange.kt
new file mode 100644
index 0000000..ac24beb
--- /dev/null
+++ b/android/app/src/main/kotlin/ru/komet/app/BleContactExchange.kt
@@ -0,0 +1,357 @@
+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, Long) -> Unit)? = null
+ var onSent: ((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 selfPhone: Long = 0L
+ @Volatile private var peerIdForWrite: Long = 0L
+ @Volatile private var connecting = false
+ @Volatile private var running = false
+
+ fun start(selfId: Long, selfSession: String, selfPhone: Long) {
+ val adapter = this.adapter
+ if (adapter == null || !adapter.isEnabled) {
+ emitError("bluetooth_off")
+ return
+ }
+ this.selfId = selfId
+ this.selfSession = selfSession
+ this.selfPhone = selfPhone
+ running = true
+ startGattServer()
+ }
+
+ fun connectTo(peerSession: String, peerId: Long) {
+ if (!running || connecting) return
+ peerIdForWrite = peerId
+ 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 parts = String(value, Charsets.UTF_8).trim().split(":")
+ val peerId = parts.getOrNull(0)?.toLongOrNull() ?: return
+ val peerPhone = parts.getOrNull(1)?.toLongOrNull() ?: 0L
+ if (peerId > 0L) emitReceived(peerId, peerPhone)
+ }
+ }
+
+ 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:$selfPhone".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,
+ ) {
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ val id = peerIdForWrite
+ if (id > 0L) emitSent(id)
+ }
+ gatt?.disconnect()
+ }
+ }
+
+ private fun emitReceived(id: Long, phone: Long) {
+ main.post { onReceived?.invoke(id, phone) }
+ }
+
+ private fun emitSent(id: Long) {
+ main.post { onSent?.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 89c4b6c..18a6d69 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
@@ -8,15 +9,22 @@ 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 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
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 +32,30 @@ 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()
+ @Volatile private var nfcCycling = false
+ private val nfcReaderCallback = NfcAdapter.ReaderCallback { tag -> onNfcTagDiscovered(tag) }
+
+ private var ble: BleContactExchange? = null
+ private var pendingSelfId = 0L
+ private var pendingSelfPhone = 0L
+ private var pendingSession = ""
+ private var pendingPeer: NfcExchange.Peer? = null
+ @Volatile private var exchangingEmitted = false
+
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
}
private fun applyIcon(name: String) {
@@ -50,6 +80,46 @@ 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 = longArg(call.argument("selfId"))
+ val selfPhone = longArg(call.argument("selfPhone"))
+ if (selfId <= 0L) {
+ result.error("INVALID_ID", "selfId must be positive", null)
+ } else {
+ startNfcExchange(selfId, selfPhone)
+ 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 +197,193 @@ class MainActivity : FlutterActivity() {
}
}
+ private fun nfcStatus(): Map {
+ val adapter = nfcAdapter
+ return mapOf(
+ "supported" to (adapter != null),
+ "enabled" to (adapter?.isEnabled == true),
+ )
+ }
+
+ private fun startNfcExchange(selfId: Long, selfPhone: Long) {
+ val session = "%08x".format(nfcJitter.nextInt())
+ NfcExchange.selfId = selfId
+ NfcExchange.selfSession = session
+ NfcExchange.selfPhone = selfPhone
+ NfcExchange.active = true
+ NfcExchange.onServed = { onNfcServed() }
+ seenPeers.clear()
+ exchangingEmitted = false
+ pendingPeer = null
+ pendingSelfId = selfId
+ pendingSelfPhone = selfPhone
+ pendingSession = session
+ nfcCycling = true
+ nfcHandler.removeCallbacksAndMessages(null)
+ nfcReaderOn()
+ ensureBleStarted()
+ }
+
+ private fun stopNfcExchange() {
+ nfcCycling = false
+ NfcExchange.active = false
+ NfcExchange.selfId = 0L
+ NfcExchange.selfSession = ""
+ NfcExchange.selfPhone = 0L
+ NfcExchange.onServed = null
+ pendingPeer = 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, phone -> revealPeer(id, phone) }
+ it.onSent = { _ -> pendingPeer?.let { p -> revealPeer(p.id, p.phone) } }
+ it.onError = { reason -> onBleError(reason) }
+ ble = it
+ }
+ exchange.start(pendingSelfId, pendingSession, pendingSelfPhone)
+ }
+
+ private fun emitExchanging() {
+ if (exchangingEmitted) return
+ exchangingEmitted = true
+ nfcEvents?.success(mapOf("event" to "exchanging"))
+ }
+
+ private fun revealPeer(id: Long, phone: Long) {
+ if (id == NfcExchange.selfId || !seenPeers.add(id)) return
+ nfcEvents?.success(mapOf("event" to "received", "id" to id, "phone" to phone))
+ }
+
+ private fun longArg(value: Any?): Long = when (value) {
+ is Int -> value.toLong()
+ is Long -> value
+ else -> 0L
+ }
+
+ 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()
+ emitExchanging()
+ }
+ }
+
+ 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() {
+ 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.parsePeer(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.id <= 0L) return
+ nfcHandler.post {
+ if (peer.id == NfcExchange.selfId) return@post
+ nfcCycling = false
+ nfcReaderDisable()
+ emitExchanging()
+ pendingPeer = peer
+ ble?.connectTo(peer.session, peer.id)
+ nfcHandler.postDelayed({ revealPeer(peer.id, peer.phone) }, 3000L)
+ }
+ }
+
+ 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
diff --git a/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt b/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt
new file mode 100644
index 0000000..ab5ccaa
--- /dev/null
+++ b/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt
@@ -0,0 +1,58 @@
+package ru.komet.app
+
+object NfcExchange {
+
+ const val AID = "F04B4F4D455431"
+
+ 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 selfPhone: Long = 0L
+
+ @Volatile var onServed: (() -> Unit)? = null
+
+ data class Peer(val id: Long, val session: String, val phone: Long)
+
+ fun buildSelectResponse(): ByteArray {
+ val id = selfId
+ val session = selfSession
+ if (!active || id <= 0L || session.isEmpty()) return STATUS_NOT_FOUND
+ onServed?.invoke()
+ return (PREFIX + id + ":" + session + ":" + selfPhone)
+ .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 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
+ 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
+ val phone = parts.getOrNull(2)?.toLongOrNull() ?: 0L
+ return Peer(id, session, phone)
+ }
+
+ 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
+ }
+}
diff --git a/android/app/src/main/kotlin/ru/komet/app/NfcHostApduService.kt b/android/app/src/main/kotlin/ru/komet/app/NfcHostApduService.kt
new file mode 100644
index 0000000..e1668e4
--- /dev/null
+++ b/android/app/src/main/kotlin/ru/komet/app/NfcHostApduService.kt
@@ -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
+ }
+}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..55b2a48
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+ Komet contact exchange
+ Komet contact exchange
+
diff --git a/android/app/src/main/res/xml/komet_nfc_apdu.xml b/android/app/src/main/res/xml/komet_nfc_apdu.xml
new file mode 100644
index 0000000..f484ffd
--- /dev/null
+++ b/android/app/src/main/res/xml/komet_nfc_apdu.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart
index 4b27c10..7822ab3 100644
--- a/lib/backend/modules/account.dart
+++ b/lib/backend/modules/account.dart
@@ -1060,6 +1060,25 @@ class AccountModule {
logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен');
}
+ Future loginWithToken(String token) async {
+ await TokenStorage.clearActiveAccount();
+ try {
+ await _api.disconnect();
+ } catch (_) {}
+
+ ContactCache.clear();
+ TranscriptionCache.clear();
+ ChatsModule.resetForAccountSwitch();
+
+ await _api.connect();
+ if (_api.state != SessionState.online) {
+ throw StateError('loginWithToken: нет соединения с сервером');
+ }
+
+ logger.i('Вход по токену: сессия поднята со спуфом, выполняю login');
+ return login(token: token);
+ }
+
Future switchAccount(int accountId) async {
final profile = await AppDatabase.loadProfile(accountId);
if (profile == null) {
diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart
index 40ba287..08f9f30 100644
--- a/lib/backend/modules/chats.dart
+++ b/lib/backend/modules/chats.dart
@@ -161,6 +161,38 @@ class CachedChat {
};
}
+class ChatSearchHit {
+ final int id;
+ final String type;
+ final String? title;
+ final String? avatarUrl;
+ final String? subtitle;
+
+ const ChatSearchHit({
+ required this.id,
+ required this.type,
+ this.title,
+ this.avatarUrl,
+ this.subtitle,
+ });
+}
+
+class MessageSearchHit {
+ final int chatId;
+ final String? messageId;
+ final String? text;
+ final int time;
+ final int senderId;
+
+ const MessageSearchHit({
+ required this.chatId,
+ this.messageId,
+ this.text,
+ required this.time,
+ required this.senderId,
+ });
+}
+
sealed class MessageEvent {
final int chatId;
const MessageEvent(this.chatId);
@@ -814,6 +846,7 @@ class ChatsModule {
Map chat,
int accountId, {
Map? preloadedExisting,
+ bool inList = true,
}) async {
final cachedAt = DateTime.now().millisecondsSinceEpoch;
final id = chat['id'];
@@ -844,7 +877,9 @@ class ChatsModule {
if (ex != null && _sameContent(ex, parsed)) {
return parsed;
}
- await AppDatabase.saveChats([parsed.toDbRow()]);
+ final row = parsed.toDbRow();
+ row['in_list'] = inList ? 1 : 0;
+ await AppDatabase.saveChats([row]);
_bump();
return parsed;
}
@@ -921,7 +956,7 @@ class ChatsModule {
),
)
.whereType()
- .map((c) => c.toDbRow())
+ .map((c) => c.toDbRow()..['in_list'] = 1)
.toList();
if (rows.isNotEmpty) {
@@ -1151,6 +1186,126 @@ class ChatsModule {
return packet.payload;
}
+ static List _parseSearchResult(dynamic payload) {
+ final result = (payload as Map?)?['result'];
+ if (result is! List) return const [];
+ final hits = [];
+ for (final item in result) {
+ if (item is! Map) continue;
+ final chat = item['chat'];
+ if (chat is! Map) continue;
+ final id = chat['id'];
+ if (id is! int) continue;
+ final last = chat['lastMessage'];
+ final link = chat['link'];
+ hits.add(ChatSearchHit(
+ id: id,
+ type: (chat['type'] as String?) ?? 'CHAT',
+ title: chat['title'] as String?,
+ avatarUrl: chat['baseIconUrl'] as String?,
+ subtitle: link is String && link.isNotEmpty
+ ? '@$link'
+ : (last is Map ? last['text'] as String? : null),
+ ));
+ }
+ return hits;
+ }
+
+ static List _parseMessageResult(dynamic payload) {
+ final result = (payload as Map?)?['result'];
+ if (result is! List) return const [];
+ final hits = [];
+ for (final item in result) {
+ if (item is! Map) continue;
+ final message = item['message'];
+ if (message is! Map) continue;
+ final chatId = item['chatId'];
+ if (chatId is! int || chatId == 0) continue;
+ hits.add(MessageSearchHit(
+ chatId: chatId,
+ messageId: message['id']?.toString(),
+ text: message['text'] as String?,
+ time: (message['time'] as int?) ?? 0,
+ senderId: (message['sender'] as int?) ?? 0,
+ ));
+ }
+ return hits;
+ }
+
+ static Future> searchMessages(
+ Api api,
+ String query, {
+ int count = 50,
+ }) async {
+ final term = query.trim();
+ if (term.isEmpty) return const [];
+ try {
+ final packet = await api.sendRequest(Opcode.chatSearch, {
+ 'count': count,
+ 'query': term,
+ });
+ if (packet.isError) return const [];
+ return _parseMessageResult(packet.payload);
+ } catch (e) {
+ logger.w('searchMessages failed: $e');
+ return const [];
+ }
+ }
+
+ static Future> searchPublic(
+ Api api,
+ String query, {
+ int count = 20,
+ }) async {
+ final term = query.trim();
+ if (term.isEmpty) return const [];
+ try {
+ final packet = await api.sendRequest(Opcode.publicSearch, {
+ 'type': 'ALL',
+ 'count': count,
+ 'query': term,
+ });
+ if (packet.isError) return const [];
+ return _parseSearchResult(packet.payload);
+ } catch (e) {
+ logger.w('searchPublic failed: $e');
+ return const [];
+ }
+ }
+
+ static Future subscribeChat(
+ Api api,
+ int chatId, {
+ bool subscribe = true,
+ }) async {
+ try {
+ await api.sendRequest(Opcode.chatSubscribe, {
+ 'chatId': chatId,
+ 'subscribe': subscribe,
+ });
+ } catch (e) {
+ logger.w('subscribeChat failed: $e');
+ }
+ }
+
+ static Future ensureChatCached(
+ Api api,
+ int accountId,
+ int chatId,
+ ) async {
+ final rows = await AppDatabase.loadChat(accountId, chatId);
+ if (rows.isNotEmpty) return true;
+ try {
+ final info = await getChatInfo(api, chatId);
+ if (info == null) return false;
+ await cacheServerChat(info, accountId, inList: false);
+ return true;
+ } catch (e) {
+ logger.w('ensureChatCached failed for $chatId: $e');
+ return false;
+ }
+ }
+
static Future createGroupChat(
Api api, {
required String title,
diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart
index ade6ad3..aed44e5 100644
--- a/lib/backend/modules/contacts.dart
+++ b/lib/backend/modules/contacts.dart
@@ -1,4 +1,8 @@
+import 'package:flutter/foundation.dart';
+
+import '../../core/protocol/opcode_map.dart';
import '../../core/storage/app_database.dart';
+import '../api.dart';
import 'messages.dart';
class CachedContact {
@@ -50,7 +54,100 @@ class CachedContact {
}
}
+class PhoneLookupResult {
+ final int id;
+ final String? name;
+ final String? avatarUrl;
+
+ const PhoneLookupResult({required this.id, this.name, this.avatarUrl});
+}
+
class ContactsModule {
+ static final ValueNotifier revision = ValueNotifier(0);
+
+ static Future findByPhone(Api api, String phone) async {
+ final normalized = _normalizePhone(phone);
+ if (normalized == null) return null;
+ final packet = await api.sendRequest(Opcode.contactInfoByPhone, {
+ 'phone': normalized,
+ });
+ if (packet.isError) return null;
+ final contact = (packet.payload as Map?)?['contact'];
+ if (contact is! Map) return null;
+ final id = contact['id'];
+ if (id is! int) return null;
+
+ String? name;
+ final names = contact['names'];
+ if (names is List) {
+ final n = names.firstWhere((e) => e is Map, orElse: () => null);
+ if (n is Map) {
+ final first =
+ (n['firstName'] as String?) ?? (n['name'] as String?) ?? '';
+ final last = (n['lastName'] as String?) ?? '';
+ final full = '$first $last'.trim();
+ if (full.isNotEmpty) name = full;
+ }
+ }
+
+ return PhoneLookupResult(
+ id: id,
+ name: name,
+ avatarUrl: contact['baseUrl'] as String?,
+ );
+ }
+
+ static String? _normalizePhone(String raw) {
+ final digits = raw.replaceAll(RegExp(r'[^\d]'), '');
+ if (digits.length < 5) return null;
+ return '+$digits';
+ }
+
+ static Future addContact(
+ Api api,
+ int id,
+ String firstName, {
+ int phone = 0,
+ }) async {
+ final resp = await api.sendRequest(Opcode.contactUpdate, {
+ 'action': 'ADD',
+ 'contactId': id,
+ 'firstName': firstName,
+ });
+
+ final profile = await AppDatabase.loadActiveProfile();
+ if (profile == null) return null;
+
+ final data = resp.payload;
+ final contact = (data is Map && data['contact'] is Map)
+ ? (data['contact'] as Map).cast()
+ : null;
+
+ final row = contact != null
+ ? _parseContact(contact, profile.id)
+ : {
+ 'id': id,
+ 'account_id': profile.id,
+ 'first_name': firstName,
+ 'last_name': null,
+ 'phone': 0,
+ 'photo_id': null,
+ 'base_url': null,
+ 'base_raw_url': null,
+ 'update_time': 0,
+ 'options': null,
+ };
+
+ if (row == null) return null;
+ if (phone > 0 && ((row['phone'] as int?) ?? 0) == 0) {
+ row['phone'] = phone;
+ }
+ await AppDatabase.saveContacts([row]);
+ if (contact != null) _primeContactCache(contact);
+ revision.value++;
+ return CachedContact.fromDbRow(row);
+ }
+
static Future syncFromLoginPayload(
Map data,
int accountId,
diff --git a/lib/core/nfc/nfc_exchange_service.dart b/lib/core/nfc/nfc_exchange_service.dart
new file mode 100644
index 0000000..898218f
--- /dev/null
+++ b/lib/core/nfc/nfc_exchange_service.dart
@@ -0,0 +1,78 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flutter/services.dart';
+
+enum NfcEventType { received, exchanging, cancelled, error }
+
+class NfcEvent {
+ final NfcEventType type;
+ final int? id;
+ final int? phone;
+ final String? reason;
+
+ const NfcEvent(this.type, this.id, {this.phone, this.reason});
+}
+
+class NfcStatus {
+ final bool supported;
+ final bool enabled;
+
+ const NfcStatus({required this.supported, required this.enabled});
+
+ bool get ready => supported && enabled;
+}
+
+class NfcExchangeService {
+ NfcExchangeService._();
+ static final NfcExchangeService instance = NfcExchangeService._();
+
+ static const MethodChannel _method = MethodChannel('ru.komet.app/nfc');
+ static const EventChannel _events = EventChannel('ru.komet.app/nfc_events');
+
+ bool get _supported => Platform.isAndroid;
+
+ Future status() async {
+ if (!_supported) return const NfcStatus(supported: false, enabled: false);
+ try {
+ final res = await _method.invokeMapMethod('status');
+ return NfcStatus(
+ supported: res?['supported'] == true,
+ enabled: res?['enabled'] == true,
+ );
+ } catch (_) {
+ return const NfcStatus(supported: false, enabled: false);
+ }
+ }
+
+ Stream get events =>
+ _events.receiveBroadcastStream().map(_decodeEvent);
+
+ Future start(int selfId, int selfPhone) =>
+ _method.invokeMethod('start', {'selfId': selfId, 'selfPhone': selfPhone});
+
+ Future stop() async {
+ if (!_supported) return;
+ try {
+ await _method.invokeMethod('stop');
+ } catch (_) {}
+ }
+
+ NfcEvent _decodeEvent(dynamic raw) {
+ final map = raw is Map ? raw : const {};
+ final id = map['id'];
+ final parsedId = id is int ? id : (id is num ? id.toInt() : null);
+ final phone = map['phone'];
+ final parsedPhone = phone is int ? phone : (phone is num ? phone.toInt() : null);
+ switch (map['event']) {
+ case 'received':
+ return NfcEvent(NfcEventType.received, parsedId, phone: parsedPhone);
+ case 'exchanging':
+ return const NfcEvent(NfcEventType.exchanging, null);
+ case 'error':
+ return NfcEvent(NfcEventType.error, null, reason: map['reason'] as String?);
+ default:
+ return const NfcEvent(NfcEventType.cancelled, null);
+ }
+ }
+}
diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart
index 83030dc..f604cbf 100644
--- a/lib/core/storage/app_database.dart
+++ b/lib/core/storage/app_database.dart
@@ -185,7 +185,7 @@ class AppDatabase {
await _migrateLegacyDb(target);
return openDatabase(
target,
- version: 13,
+ version: 14,
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: (db, _) => _createTables(db),
onUpgrade: (db, oldVersion, newVersion) async {
@@ -248,6 +248,11 @@ class AppDatabase {
'ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0',
);
}
+ if (oldVersion < 14) {
+ await db.execute(
+ 'ALTER TABLE chats_cache ADD COLUMN in_list INTEGER NOT NULL DEFAULT 1',
+ );
+ }
},
);
}
@@ -335,6 +340,7 @@ class AppDatabase {
options TEXT,
owner INTEGER,
admins TEXT,
+ in_list INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (id, account_id)
)
''';
@@ -534,7 +540,7 @@ class AppDatabase {
final db = await _instance;
return db.query(
'chats_cache',
- where: 'account_id = ?',
+ where: 'account_id = ? AND in_list = 1',
whereArgs: [accountId],
orderBy: 'last_event_time DESC',
);
@@ -543,8 +549,8 @@ class AppDatabase {
static Future sumUnread(int accountId, {int? excludeChatId}) async {
final db = await _instance;
final where = excludeChatId != null
- ? 'account_id = ? AND id != ?'
- : 'account_id = ?';
+ ? 'account_id = ? AND in_list = 1 AND id != ?'
+ : 'account_id = ? AND in_list = 1';
final args = excludeChatId != null
? [accountId, excludeChatId]
: [accountId];
@@ -578,6 +584,47 @@ class AppDatabase {
);
}
+ static String _escapeLike(String value) =>
+ value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
+
+ static Future>> searchContacts(
+ int accountId,
+ String query, {
+ int limit = 30,
+ }) async {
+ final term = query.trim();
+ if (term.isEmpty) return const [];
+ final db = await _instance;
+ final like = '%${_escapeLike(term)}%';
+ return db.query(
+ 'contacts',
+ where: 'account_id = ? AND '
+ "(first_name LIKE ? ESCAPE '\\' OR last_name LIKE ? ESCAPE '\\' "
+ "OR CAST(phone AS TEXT) LIKE ? ESCAPE '\\')",
+ whereArgs: [accountId, like, like, like],
+ orderBy: 'first_name ASC, last_name ASC',
+ limit: limit,
+ );
+ }
+
+ static Future>> searchChatsByTitle(
+ int accountId,
+ String query, {
+ int limit = 30,
+ }) async {
+ final term = query.trim();
+ if (term.isEmpty) return const [];
+ final db = await _instance;
+ final like = '%${_escapeLike(term)}%';
+ return db.query(
+ 'chats_cache',
+ where: "account_id = ? AND title LIKE ? ESCAPE '\\'",
+ whereArgs: [accountId, like],
+ orderBy: 'last_event_time DESC',
+ limit: limit,
+ );
+ }
+
static Future>> loadChatsByIds(
int accountId,
List ids,
diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart
index 15b037d..ad18758 100644
--- a/lib/frontend/screens/auth/login_screen.dart
+++ b/lib/frontend/screens/auth/login_screen.dart
@@ -9,6 +9,7 @@ import 'package:komet/l10n/app_localizations.dart';
import 'package:komet/l10n/terms_of_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'code_confirmation_screen.dart';
+import 'token_login_screen.dart';
import 'select_country_screen.dart';
import 'proxy_settings_sheet.dart';
import 'server_settings_sheet.dart';
@@ -654,6 +655,14 @@ class _LoginScreenState extends State {
),
onTap: () {
Navigator.pop(context);
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => TokenLoginScreen(
+ returnToAccountId: widget.returnToAccountId,
+ ),
+ ),
+ );
},
),
ListTile(
diff --git a/lib/frontend/screens/auth/token_login_screen.dart b/lib/frontend/screens/auth/token_login_screen.dart
new file mode 100644
index 0000000..77ef864
--- /dev/null
+++ b/lib/frontend/screens/auth/token_login_screen.dart
@@ -0,0 +1,337 @@
+import 'package:flutter/material.dart';
+import 'package:material_symbols_icons/symbols.dart';
+
+import '../../../core/storage/spoofing_service.dart';
+import '../../../l10n/app_localizations.dart';
+import '../../../main.dart';
+import '../../../models/spoof_profile.dart';
+import '../../widgets/adaptive_shell.dart';
+import '../../widgets/custom_notification.dart';
+import '../../widgets/section_header.dart';
+
+class TokenLoginScreen extends StatefulWidget {
+ final int? returnToAccountId;
+
+ const TokenLoginScreen({super.key, this.returnToAccountId});
+
+ @override
+ State createState() => _TokenLoginScreenState();
+}
+
+class _TokenLoginScreenState extends State {
+ final _tokenController = TextEditingController();
+ final _deviceNameController = TextEditingController();
+ final _osVersionController = TextEditingController();
+ final _screenController = TextEditingController();
+ final _timezoneController = TextEditingController();
+ final _localeController = TextEditingController();
+ final _deviceLocaleController = TextEditingController();
+ final _deviceIdController = TextEditingController();
+ final _appVersionController = TextEditingController(
+ text: SpoofingService.hardcodedAppVersion,
+ );
+ final _buildNumberController = TextEditingController(
+ text: '${SpoofingService.hardcodedBuildNumber}',
+ );
+ final _pushDeviceTypeController = TextEditingController(text: 'GCM');
+ final _instanceIdController = TextEditingController();
+ final _clientSessionIdController = TextEditingController();
+ final _userAgentController = TextEditingController();
+
+ String _selectedDeviceType = 'ANDROID';
+ String _selectedArch = 'arm64-v8a';
+ bool _isLoading = false;
+
+ @override
+ void dispose() {
+ _tokenController.dispose();
+ _deviceNameController.dispose();
+ _osVersionController.dispose();
+ _screenController.dispose();
+ _timezoneController.dispose();
+ _localeController.dispose();
+ _deviceLocaleController.dispose();
+ _deviceIdController.dispose();
+ _appVersionController.dispose();
+ _buildNumberController.dispose();
+ _pushDeviceTypeController.dispose();
+ _instanceIdController.dispose();
+ _clientSessionIdController.dispose();
+ _userAgentController.dispose();
+ super.dispose();
+ }
+
+ bool get _isValid =>
+ _tokenController.text.trim().isNotEmpty &&
+ _deviceNameController.text.trim().isNotEmpty &&
+ _osVersionController.text.trim().isNotEmpty &&
+ _deviceIdController.text.trim().isNotEmpty;
+
+ Future _login() async {
+ final l10n = AppLocalizations.of(context)!;
+ if (!_isValid) {
+ showCustomNotification(context, l10n.tokenLoginError);
+ return;
+ }
+
+ setState(() => _isLoading = true);
+
+ final profile = SpoofProfile(
+ enabled: true,
+ deviceName: _deviceNameController.text.trim(),
+ osVersion: _osVersionController.text.trim(),
+ screen: _screenController.text.trim(),
+ timezone: _timezoneController.text.trim(),
+ locale: _localeController.text.trim(),
+ deviceLocale: _deviceLocaleController.text.trim(),
+ deviceId: _deviceIdController.text.trim(),
+ deviceType: _selectedDeviceType,
+ arch: _selectedArch,
+ appVersion: _appVersionController.text.trim(),
+ buildNumber: int.tryParse(_buildNumberController.text.trim()) ??
+ SpoofingService.hardcodedBuildNumber,
+ pushDeviceType: _pushDeviceTypeController.text.trim(),
+ instanceId: _instanceIdController.text.trim(),
+ clientSessionId: int.tryParse(_clientSessionIdController.text.trim()),
+ userAgent: _userAgentController.text.trim(),
+ );
+
+ try {
+ await SpoofingService.saveProfile(SpoofingService.pendingScope, profile);
+ await accountModule.loginWithToken(_tokenController.text.trim());
+ if (!mounted) return;
+ await Navigator.of(context).pushAndRemoveUntil(
+ MaterialPageRoute(builder: (_) => const AdaptiveShell()),
+ (route) => false,
+ );
+ } catch (e) {
+ if (!mounted) return;
+ setState(() => _isLoading = false);
+ showCustomNotification(context, '${l10n.tokenLoginFailed}: $e');
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final l10n = AppLocalizations.of(context)!;
+ return Scaffold(
+ appBar: AppBar(title: Text(l10n.tokenLoginTitle), centerTitle: true),
+ body: AbsorbPointer(
+ absorbing: _isLoading,
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 120),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _buildNoteCard(l10n),
+ const SizedBox(height: 16),
+ _buildTokenCard(l10n),
+ const SizedBox(height: 16),
+ _buildDeviceCard(l10n),
+ ],
+ ),
+ ),
+ ),
+ floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
+ floatingActionButton: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: FilledButton(
+ onPressed: _isLoading ? null : _login,
+ style: FilledButton.styleFrom(
+ minimumSize: const Size.fromHeight(52),
+ shape: const StadiumBorder(),
+ ),
+ child: _isLoading
+ ? const SizedBox(
+ width: 22,
+ height: 22,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : Text(l10n.tokenLoginButton),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildNoteCard(AppLocalizations l10n) {
+ final cs = Theme.of(context).colorScheme;
+ return Card(
+ color: cs.secondaryContainer.withValues(alpha: 0.5),
+ elevation: 0,
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Row(
+ children: [
+ Icon(Symbols.warning, size: 20, color: cs.onSecondaryContainer),
+ const SizedBox(width: 8),
+ Flexible(
+ child: Text(
+ l10n.tokenLoginNote,
+ style: TextStyle(fontSize: 13, color: cs.onSecondaryContainer),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildTokenCard(AppLocalizations l10n) {
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: TextField(
+ controller: _tokenController,
+ minLines: 1,
+ maxLines: 3,
+ onChanged: (_) => setState(() {}),
+ decoration: _decoration(l10n.tokenLoginTokenLabel, Symbols.key),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildDeviceCard(AppLocalizations l10n) {
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SectionHeader(
+ l10n.spoofMainSectionTitle,
+ padding: const EdgeInsets.only(bottom: 16, top: 4),
+ fontSize: 20,
+ ),
+ Text(l10n.spoofDeviceTypeTitle),
+ const SizedBox(height: 8),
+ _chips(
+ const [
+ _Opt('ANDROID', 'Android', Symbols.android),
+ _Opt('IOS', 'iOS', Symbols.phone_iphone),
+ ],
+ _selectedDeviceType,
+ (v) => setState(() => _selectedDeviceType = v),
+ ),
+ const SizedBox(height: 16),
+ _field(_deviceNameController, l10n.spoofFieldDeviceName,
+ Symbols.smartphone),
+ const SizedBox(height: 16),
+ _field(_osVersionController, l10n.spoofFieldOsVersion,
+ Symbols.layers),
+ const SizedBox(height: 16),
+ _field(_screenController, l10n.spoofFieldScreen, Symbols.fullscreen),
+ const SizedBox(height: 16),
+ _field(_timezoneController, l10n.spoofFieldTimezone, Symbols.public),
+ const SizedBox(height: 16),
+ _field(_localeController, l10n.spoofFieldLocale, Symbols.language),
+ const SizedBox(height: 16),
+ _field(_deviceLocaleController, l10n.spoofFieldDeviceLocale,
+ Symbols.translate),
+ const SizedBox(height: 24),
+ SectionHeader(
+ l10n.spoofIdentifiersSectionTitle,
+ padding: const EdgeInsets.only(bottom: 16, top: 4),
+ fontSize: 20,
+ ),
+ _field(_deviceIdController, l10n.spoofFieldDeviceId, Symbols.tag,
+ onChanged: true),
+ const SizedBox(height: 16),
+ _field(_instanceIdController, l10n.spoofFieldInstanceId,
+ Symbols.fingerprint),
+ const SizedBox(height: 16),
+ _field(_clientSessionIdController, l10n.spoofFieldClientSessionId,
+ Symbols.vpn_key,
+ number: true),
+ const SizedBox(height: 16),
+ _field(_appVersionController, l10n.spoofFieldAppVersion,
+ Symbols.info),
+ const SizedBox(height: 16),
+ _field(_buildNumberController, l10n.spoofFieldBuildNumber,
+ Symbols.numbers,
+ number: true),
+ const SizedBox(height: 16),
+ _field(_pushDeviceTypeController, l10n.spoofFieldPushDeviceType,
+ Symbols.notifications),
+ const SizedBox(height: 16),
+ Text(l10n.spoofFieldArchitecture),
+ const SizedBox(height: 8),
+ _chips(
+ const [
+ _Opt('arm64-v8a', 'arm64-v8a', Symbols.memory),
+ _Opt('armeabi-v7a', 'armeabi-v7a', Symbols.memory),
+ _Opt('arm64', 'arm64', Symbols.memory),
+ _Opt('x86_64', 'x86_64', Symbols.memory),
+ _Opt('x86', 'x86', Symbols.memory),
+ ],
+ _selectedArch,
+ (v) => setState(() => _selectedArch = v),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _field(
+ TextEditingController controller,
+ String label,
+ IconData icon, {
+ bool number = false,
+ bool onChanged = false,
+ }) {
+ return TextField(
+ controller: controller,
+ keyboardType: number ? TextInputType.number : null,
+ onChanged: onChanged ? (_) => setState(() {}) : null,
+ decoration: _decoration(label, icon),
+ );
+ }
+
+ InputDecoration _decoration(String label, IconData icon) {
+ return InputDecoration(
+ labelText: label,
+ prefixIcon: Icon(icon),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
+ filled: true,
+ fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
+ );
+ }
+
+ Widget _chips(
+ List<_Opt> options,
+ String selected,
+ ValueChanged onSelected,
+ ) {
+ final cs = Theme.of(context).colorScheme;
+ return Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: options.map((opt) {
+ final isSelected = opt.value == selected;
+ return ChoiceChip(
+ label: Text(opt.label),
+ avatar: isSelected
+ ? Icon(Icons.check, size: 18, color: cs.onSecondaryContainer)
+ : Icon(opt.icon, size: 18, color: cs.onSurfaceVariant),
+ selected: isSelected,
+ showCheckmark: false,
+ onSelected: (_) => onSelected(opt.value),
+ backgroundColor: cs.surfaceContainerHighest,
+ selectedColor: cs.secondaryContainer,
+ side: BorderSide(
+ color: isSelected ? Colors.transparent : cs.outlineVariant,
+ ),
+ );
+ }).toList(),
+ );
+ }
+}
+
+class _Opt {
+ final String value;
+ final String label;
+ final IconData icon;
+
+ const _Opt(this.value, this.label, this.icon);
+}
diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart
index c8b0797..19a852d 100644
--- a/lib/frontend/screens/chats/chat_list_screen.dart
+++ b/lib/frontend/screens/chats/chat_list_screen.dart
@@ -8,6 +8,7 @@ import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/gestures.dart';
import 'chat_screen.dart';
+import 'search_screen.dart';
import 'create_group_flow.dart';
import '../../widgets/adaptive_shell.dart';
import '../../widgets/online_dot.dart';
@@ -1271,43 +1272,39 @@ class _ChatListScreenState extends State
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 3, 20, 8),
- child: GlossyPill(
- color: cs.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(50),
- padding: const EdgeInsets.symmetric(
- horizontal: 16,
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () => pushSwipeable(
+ context,
+ (_) => const SearchScreen(),
),
- depth: 6,
- child: SizedBox(
- height: 44,
- child: Row(
- children: [
- Icon(
- Symbols.search,
- color: cs.outline,
- size: 20,
- weight: 400,
- ),
- const SizedBox(width: 10),
- Expanded(
- child: TextField(
+ child: GlossyPill(
+ color: cs.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(50),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ ),
+ depth: 6,
+ child: SizedBox(
+ height: 44,
+ child: Row(
+ children: [
+ Icon(
+ Symbols.search,
+ color: cs.outline,
+ size: 20,
+ weight: 400,
+ ),
+ const SizedBox(width: 10),
+ Text(
+ 'Поиск',
style: TextStyle(
- color: cs.onSurface,
+ color: cs.outline,
fontSize: 15,
),
- decoration: InputDecoration(
- hintText: 'Поиск',
- hintStyle: TextStyle(
- color: cs.outline,
- fontSize: 15,
- ),
- border: InputBorder.none,
- isDense: true,
- contentPadding: EdgeInsets.zero,
- ),
),
- ),
- ],
+ ],
+ ),
),
),
),
diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart
index 26b7353..3364e7d 100644
--- a/lib/frontend/screens/chats/chat_screen.dart
+++ b/lib/frontend/screens/chats/chat_screen.dart
@@ -164,6 +164,7 @@ class _ChatScreenState extends State
late AnimationController _shimmerController;
Timer? _shimmerStartTimer;
bool _historyKickedOff = false;
+ bool _previewChat = false;
List _messages = [];
final ValueNotifier _messagesRev = ValueNotifier(0);
final Set _deletingIds = {};
@@ -409,6 +410,12 @@ class _ChatScreenState extends State
}
try {
+ final cachedRows = await AppDatabase.loadChat(_myId, widget.chatId);
+ if (cachedRows.isEmpty) {
+ _previewChat = true;
+ await ChatsModule.ensureChatCached(api, _myId, widget.chatId);
+ await ChatsModule.subscribeChat(api, widget.chatId);
+ }
final serverMessages = await messagesModule.fetchHistory(
_myId,
widget.chatId,
@@ -532,6 +539,9 @@ class _ChatScreenState extends State
@override
void dispose() {
+ if (_previewChat) {
+ unawaited(ChatsModule.subscribeChat(api, widget.chatId, subscribe: false));
+ }
WidgetsBinding.instance.removeObserver(this);
ChatsModule.chatsChanged.removeListener(_onChatsBump);
_otherUnread.dispose();
diff --git a/lib/frontend/screens/chats/search_screen.dart b/lib/frontend/screens/chats/search_screen.dart
new file mode 100644
index 0000000..e042a18
--- /dev/null
+++ b/lib/frontend/screens/chats/search_screen.dart
@@ -0,0 +1,393 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:material_symbols_icons/symbols.dart';
+
+import '../../../main.dart';
+import '../../../backend/modules/chats.dart';
+import '../../../backend/modules/contacts.dart';
+import '../../../core/storage/app_database.dart';
+import '../../widgets/komet_avatar.dart';
+import '../../widgets/swipe_route.dart';
+import '../contacts/contact_profile_screen.dart';
+import 'chat_screen.dart';
+
+class SearchScreen extends StatefulWidget {
+ const SearchScreen({super.key});
+
+ @override
+ State createState() => _SearchScreenState();
+}
+
+class _SearchScreenState extends State {
+ final _controller = TextEditingController();
+ final _focusNode = FocusNode();
+ Timer? _debounce;
+ int _seq = 0;
+ int? _accountId;
+
+ bool _loading = false;
+ PhoneLookupResult? _phoneResult;
+ List