Merge pull request #36 from KometTeam/feature/nfc-contact-exchange

Feature/nfc contact exchange
This commit is contained in:
klockky
2026-06-21 17:33:53 +03:00
committed by GitHub
24 changed files with 2548 additions and 39 deletions
+17
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"/>
@@ -15,6 +16,11 @@
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<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}"
@@ -67,6 +73,17 @@
android:name=".UploadForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<service
android:name=".NfcHostApduService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/komet_nfc_apdu"/>
</service>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -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<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 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()
}
}
@@ -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<Long>()
@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<Any>("selfId"))
val selfPhone = longArg(call.argument<Any>("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<String, Any> {
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<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, 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<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() {
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
@@ -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
}
}
@@ -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
}
}
@@ -0,0 +1,4 @@
<resources>
<string name="nfc_service_description">Komet contact exchange</string>
<string name="nfc_aid_group_description">Komet contact exchange</string>
</resources>
@@ -0,0 +1,9 @@
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/nfc_service_description"
android:requireDeviceUnlock="false">
<aid-group
android:description="@string/nfc_aid_group_description"
android:category="other">
<aid-filter android:name="F04B4F4D455431"/>
</aid-group>
</host-apdu-service>
+19
View File
@@ -1060,6 +1060,25 @@ class AccountModule {
logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен');
}
Future<LoginResult> 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<ProfileData> switchAccount(int accountId) async {
final profile = await AppDatabase.loadProfile(accountId);
if (profile == null) {
+157 -2
View File
@@ -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<dynamic, dynamic> chat,
int accountId, {
Map<int, CachedChat>? 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<CachedChat>()
.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<ChatSearchHit> _parseSearchResult(dynamic payload) {
final result = (payload as Map?)?['result'];
if (result is! List) return const [];
final hits = <ChatSearchHit>[];
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<MessageSearchHit> _parseMessageResult(dynamic payload) {
final result = (payload as Map?)?['result'];
if (result is! List) return const [];
final hits = <MessageSearchHit>[];
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<List<MessageSearchHit>> 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<List<ChatSearchHit>> 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<void> 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<bool> 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<CachedChat?> createGroupChat(
Api api, {
required String title,
+97
View File
@@ -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<int> revision = ValueNotifier<int>(0);
static Future<PhoneLookupResult?> 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<CachedContact?> 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<dynamic, dynamic>()
: 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<void> syncFromLoginPayload(
Map<dynamic, dynamic> data,
int accountId,
+78
View File
@@ -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<NfcStatus> status() async {
if (!_supported) return const NfcStatus(supported: false, enabled: false);
try {
final res = await _method.invokeMapMethod<String, dynamic>('status');
return NfcStatus(
supported: res?['supported'] == true,
enabled: res?['enabled'] == true,
);
} catch (_) {
return const NfcStatus(supported: false, enabled: false);
}
}
Stream<NfcEvent> get events =>
_events.receiveBroadcastStream().map(_decodeEvent);
Future<void> start(int selfId, int selfPhone) =>
_method.invokeMethod('start', {'selfId': selfId, 'selfPhone': selfPhone});
Future<void> 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);
}
}
}
+51 -4
View File
@@ -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<int> 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> loadChatsByIds(
int accountId,
List<int> ids,
@@ -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<LoginScreen> {
),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => TokenLoginScreen(
returnToAccountId: widget.returnToAccountId,
),
),
);
},
),
ListTile(
@@ -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<TokenLoginScreen> createState() => _TokenLoginScreenState();
}
class _TokenLoginScreenState extends State<TokenLoginScreen> {
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<void> _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<String> 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);
}
@@ -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<ChatListScreen>
),
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,
),
),
),
],
],
),
),
),
),
@@ -164,6 +164,7 @@ class _ChatScreenState extends State<ChatScreen>
late AnimationController _shimmerController;
Timer? _shimmerStartTimer;
bool _historyKickedOff = false;
bool _previewChat = false;
List<CachedMessage> _messages = [];
final ValueNotifier<int> _messagesRev = ValueNotifier(0);
final Set<String> _deletingIds = {};
@@ -409,6 +410,12 @@ class _ChatScreenState extends State<ChatScreen>
}
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<ChatScreen>
@override
void dispose() {
if (_previewChat) {
unawaited(ChatsModule.subscribeChat(api, widget.chatId, subscribe: false));
}
WidgetsBinding.instance.removeObserver(this);
ChatsModule.chatsChanged.removeListener(_onChatsBump);
_otherUnread.dispose();
@@ -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<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
Timer? _debounce;
int _seq = 0;
int? _accountId;
bool _loading = false;
PhoneLookupResult? _phoneResult;
List<Map<String, dynamic>> _contacts = const [];
List<Map<String, dynamic>> _chats = const [];
List<MessageSearchHit> _messages = const [];
Map<int, Map<String, dynamic>> _msgChatMeta = const {};
List<ChatSearchHit> _public = const [];
@override
void initState() {
super.initState();
AppDatabase.loadActiveProfile().then((p) {
if (mounted) _accountId = p?.id;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusNode.requestFocus();
});
}
@override
void dispose() {
_debounce?.cancel();
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
void _onChanged(String value) {
_debounce?.cancel();
if (value.trim().isEmpty) {
_seq++;
setState(() {
_loading = false;
_phoneResult = null;
_contacts = const [];
_chats = const [];
_messages = const [];
_msgChatMeta = const {};
_public = const [];
});
return;
}
if (_phoneResult != null) {
setState(() => _phoneResult = null);
}
_debounce = Timer(const Duration(milliseconds: 300), _runSearch);
}
Future<void> _runSearch() async {
final query = _controller.text.trim();
if (query.isEmpty) return;
final token = ++_seq;
setState(() => _loading = true);
final accountId = _accountId;
final phoneQuery = _phoneCandidate(query);
final results = await Future.wait([
accountId == null
? Future.value(const <Map<String, dynamic>>[])
: AppDatabase.searchContacts(accountId, query),
accountId == null
? Future.value(const <Map<String, dynamic>>[])
: AppDatabase.searchChatsByTitle(accountId, query),
ChatsModule.searchMessages(api, query),
ChatsModule.searchPublic(api, query),
phoneQuery == null
? Future<PhoneLookupResult?>.value(null)
: ContactsModule.findByPhone(api, phoneQuery),
]);
if (!mounted || token != _seq) return;
final chats = results[1] as List<Map<String, dynamic>>;
final messages = results[2] as List<MessageSearchHit>;
final localChatIds = chats.map((c) => c['id'] as int).toSet();
final public = (results[3] as List<ChatSearchHit>)
.where((c) => !localChatIds.contains(c.id))
.toList();
var meta = <int, Map<String, dynamic>>{};
if (accountId != null && messages.isNotEmpty) {
final ids = messages.map((m) => m.chatId).toSet().toList();
final rows = await AppDatabase.loadChatsByIds(accountId, ids);
meta = {for (final r in rows) r['id'] as int: r};
if (!mounted || token != _seq) return;
}
setState(() {
_phoneResult = results[4] as PhoneLookupResult?;
_contacts = results[0] as List<Map<String, dynamic>>;
_chats = chats;
_messages = messages;
_msgChatMeta = meta;
_public = public;
_loading = false;
});
}
String _contactName(Map<String, dynamic> row) {
final first = (row['first_name'] as String?)?.trim() ?? '';
final last = (row['last_name'] as String?)?.trim() ?? '';
final name = '$first $last'.trim();
return name.isEmpty ? '+${row['phone']}' : name;
}
void _openChat(int chatId, String name, String? avatarUrl, String type) {
pushSwipeable(
context,
(_) => ChatScreen(
chatId: chatId,
name: name,
imageUrl: avatarUrl ?? '',
chatType: type,
),
);
}
void _openContact(Map<String, dynamic> row) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ContactProfileScreen(
contactId: row['id'] as int,
initialName: _contactName(row),
initialAvatarUrl: row['base_url'] as String?,
),
),
);
}
String? _phoneCandidate(String query) {
if (!RegExp(r'^[+\d\s\-()]+$').hasMatch(query)) return null;
final digits = query.replaceAll(RegExp(r'[^\d]'), '');
if (digits.length < 5) return null;
return query;
}
void _openPhoneResult(PhoneLookupResult result) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ContactProfileScreen(
contactId: result.id,
initialName: result.name,
initialAvatarUrl: result.avatarUrl,
),
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final query = _controller.text.trim();
final hasResults = _phoneResult != null ||
_contacts.isNotEmpty ||
_chats.isNotEmpty ||
_messages.isNotEmpty ||
_public.isNotEmpty;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surface,
elevation: 0,
scrolledUnderElevation: 0,
titleSpacing: 0,
leading: IconButton(
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
onPressed: () => Navigator.of(context).pop(),
),
title: TextField(
controller: _controller,
focusNode: _focusNode,
onChanged: _onChanged,
style: TextStyle(color: cs.onSurface, fontSize: 16),
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Поиск',
hintStyle: TextStyle(color: cs.outline, fontSize: 16),
border: InputBorder.none,
isDense: true,
),
),
actions: [
if (query.isNotEmpty)
IconButton(
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
onPressed: () {
_controller.clear();
_onChanged('');
_focusNode.requestFocus();
},
),
],
),
body: _buildBody(cs, query, hasResults),
);
}
Widget _buildBody(ColorScheme cs, String query, bool hasResults) {
if (query.isEmpty) {
return _buildHint(cs, Symbols.search, 'Начните вводить запрос');
}
if (!hasResults) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
return _buildHint(cs, Symbols.search_off, 'Ничего не найдено');
}
final phoneResult = _phoneResult;
return ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
children: [
if (_loading)
const LinearProgressIndicator(minHeight: 2),
if (phoneResult != null) ...[
_sectionHeader(cs, 'По номеру'),
_ResultTile(
name: phoneResult.name ?? '',
imageUrl: phoneResult.avatarUrl,
subtitle: query,
onTap: () => _openPhoneResult(phoneResult),
),
],
if (_contacts.isNotEmpty) ...[
_sectionHeader(cs, 'Контакты'),
for (final row in _contacts)
_ResultTile(
name: _contactName(row),
imageUrl: row['base_url'] as String?,
subtitle: '+${row['phone']}',
onTap: () => _openContact(row),
),
],
if (_chats.isNotEmpty) ...[
_sectionHeader(cs, 'Чаты'),
for (final row in _chats)
_ResultTile(
name: (row['title'] as String?) ?? '',
imageUrl: row['icon_url'] as String?,
onTap: () => _openChat(
row['id'] as int,
(row['title'] as String?) ?? '',
row['icon_url'] as String?,
(row['type'] as String?) ?? 'CHAT',
),
),
],
if (_messages.isNotEmpty) ...[
_sectionHeader(cs, 'Сообщения'),
for (final hit in _messages) _messageTile(hit),
],
if (_public.isNotEmpty) ...[
_sectionHeader(cs, 'Глобальный поиск'),
for (final hit in _public) _chatTile(hit),
],
const SizedBox(height: 16),
],
);
}
Widget _chatTile(ChatSearchHit hit) => _ResultTile(
name: hit.title ?? '',
imageUrl: hit.avatarUrl,
subtitle: hit.subtitle,
onTap: () => _openChat(hit.id, hit.title ?? '', hit.avatarUrl, hit.type),
);
Widget _messageTile(MessageSearchHit hit) {
final meta = _msgChatMeta[hit.chatId];
final title = (meta?['title'] as String?) ?? 'Чат';
final icon = meta?['icon_url'] as String?;
final type = (meta?['type'] as String?) ?? 'CHAT';
return _ResultTile(
name: title,
imageUrl: icon,
subtitle: hit.text?.trim(),
onTap: () => _openChat(hit.chatId, title, icon, type),
);
}
Widget _sectionHeader(ColorScheme cs, String title) => Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 6),
child: Text(
title,
style: TextStyle(
color: cs.primary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
);
Widget _buildHint(ColorScheme cs, IconData icon, String text) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: cs.outline),
const SizedBox(height: 12),
Text(text, style: TextStyle(color: cs.outline, fontSize: 15)),
],
),
);
}
class _ResultTile extends StatelessWidget {
final String name;
final String? imageUrl;
final String? subtitle;
final VoidCallback onTap;
const _ResultTile({
required this.name,
required this.onTap,
this.imageUrl,
this.subtitle,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final sub = subtitle?.trim();
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
children: [
KometAvatar(name: name, size: 48, imageUrl: imageUrl),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name.isEmpty ? 'Без названия' : name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
if (sub != null && sub.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
),
),
],
],
),
),
],
),
),
);
}
}
@@ -9,6 +9,7 @@ import '../../widgets/komet_avatar.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/sheet_helpers.dart';
import 'contact_profile_screen.dart';
import 'nfc_exchange_sheet.dart';
class ContactsTab extends StatefulWidget {
const ContactsTab({super.key});
@@ -25,6 +26,37 @@ class _ContactsTabState extends State<ContactsTab> {
void initState() {
super.initState();
_loadContacts();
ContactsModule.revision.addListener(_loadContacts);
}
@override
void dispose() {
ContactsModule.revision.removeListener(_loadContacts);
super.dispose();
}
Future<void> _openNfcExchange() async {
await showGeneralDialog<void>(
context: context,
barrierDismissible: true,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.black54,
transitionDuration: const Duration(milliseconds: 320),
pageBuilder: (_, _, _) => const Align(
alignment: Alignment.topCenter,
child: NfcExchangeSheet(),
),
transitionBuilder: (_, anim, _, child) {
final curved = CurvedAnimation(parent: anim, curve: Curves.easeOutCubic);
return SlideTransition(
position: Tween(
begin: const Offset(0, -1),
end: Offset.zero,
).animate(curved),
child: child,
);
},
);
}
Future<void> _openSearchById() async {
@@ -188,7 +220,7 @@ class _ContactsTabState extends State<ContactsTab> {
),
IconButton(
icon: Icon(Symbols.person_add, color: cs.onSurface),
onPressed: () {},
onPressed: _openNfcExchange,
),
IconButton(
icon: Icon(Symbols.search, color: cs.onSurface),
@@ -0,0 +1,520 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/contacts.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/nfc/nfc_exchange_service.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/format.dart';
import '../../../main.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/komet_avatar.dart';
enum _Stage {
checking,
unsupported,
disabled,
failed,
scanning,
exchanging,
found,
adding,
added,
}
class NfcExchangeSheet extends StatefulWidget {
const NfcExchangeSheet({super.key});
@override
State<NfcExchangeSheet> createState() => _NfcExchangeSheetState();
}
class _NfcExchangeSheetState extends State<NfcExchangeSheet>
with TickerProviderStateMixin {
final _nfc = NfcExchangeService.instance;
late final AnimationController _pulse;
late final AnimationController _reveal;
StreamSubscription<NfcEvent>? _sub;
_Stage _stage = _Stage.checking;
int? _peerId;
int? _peerPhone;
Map<String, dynamic>? _peerInfo;
String _failReason = '';
@override
void initState() {
super.initState();
_pulse = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
)..repeat();
_reveal = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1100),
);
_begin();
}
@override
void dispose() {
_sub?.cancel();
_nfc.stop();
_pulse.dispose();
_reveal.dispose();
super.dispose();
}
Future<void> _begin() async {
final status = await _nfc.status();
if (!mounted) return;
if (!status.supported) {
setState(() => _stage = _Stage.unsupported);
return;
}
if (!status.enabled) {
setState(() => _stage = _Stage.disabled);
return;
}
final profile = await AppDatabase.loadActiveProfile();
if (!mounted) return;
if (profile == null) {
setState(() => _stage = _Stage.unsupported);
return;
}
_sub = _nfc.events.listen(_onEvent);
await _nfc.start(profile.id, profile.phone);
if (mounted) setState(() => _stage = _Stage.scanning);
}
Future<void> _onEvent(NfcEvent event) async {
if (event.type == NfcEventType.cancelled) {
if (mounted && _stage == _Stage.scanning) {
setState(() => _stage = _Stage.disabled);
}
return;
}
if (event.type == NfcEventType.error) {
if (mounted && _peerId == null) {
setState(() {
_failReason = _reasonText(event.reason);
_stage = _Stage.failed;
});
}
return;
}
if (event.type == NfcEventType.exchanging) {
if (mounted && _peerId == null && _stage == _Stage.scanning) {
setState(() => _stage = _Stage.exchanging);
}
return;
}
final id = event.id;
if (id == null || _peerId != null) return;
_peerId = id;
_peerPhone = (event.phone != null && event.phone! > 0) ? event.phone : null;
HapticFeedback.mediumImpact();
_reveal.forward(from: 0);
setState(() => _stage = _Stage.found);
final info = await ContactInfoFetch.get(id);
if (!mounted) return;
setState(() => _peerInfo = info);
}
String _peerName() {
final info = _peerInfo;
if (info != null) {
final names = info['names'];
if (names is List && names.isNotEmpty) {
for (final n in names) {
if (n is! Map) continue;
final full = n['name']?.toString();
if (full != null && full.isNotEmpty) return full;
final first = n['firstName']?.toString() ?? '';
final last = n['lastName']?.toString() ?? '';
final combined = '$first $last'.trim();
if (combined.isNotEmpty) return combined;
}
}
}
return 'Контакт #${_peerId ?? ''}';
}
String _firstNameForAdd() {
final info = _peerInfo;
if (info != null) {
final names = info['names'];
if (names is List && names.isNotEmpty) {
for (final n in names) {
if (n is! Map) continue;
final first = n['firstName']?.toString();
if (first != null && first.isNotEmpty) return first;
final full = n['name']?.toString();
if (full != null && full.isNotEmpty) return full;
}
}
}
return 'Контакт';
}
Future<void> _add() async {
final id = _peerId;
if (id == null) return;
setState(() => _stage = _Stage.adding);
try {
await ContactsModule.addContact(
api,
id,
_firstNameForAdd(),
phone: _peerPhone ?? 0,
);
if (!mounted) return;
setState(() => _stage = _Stage.added);
showCustomNotification(context, 'Контакт добавлен');
await Future.delayed(const Duration(milliseconds: 700));
if (mounted) Navigator.pop(context);
} catch (e) {
if (!mounted) return;
setState(() => _stage = _Stage.found);
showCustomNotification(context, 'Не удалось добавить: $e');
}
}
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;
return SizedBox(
width: double.infinity,
child: Material(
color: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(28)),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
'Обмен контактом',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
),
],
),
const SizedBox(height: 12),
AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(scale: animation, child: child),
),
child: KeyedSubtree(
key: ValueKey(_stage == _Stage.found ? 'found' : _stage.name),
child: _buildContent(cs),
),
),
],
),
),
),
),
);
}
Widget _buildContent(ColorScheme cs) {
switch (_stage) {
case _Stage.checking:
return const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: CircularProgressIndicator(),
);
case _Stage.unsupported:
return _message(cs, Symbols.nfc, 'NFC недоступен на этом устройстве');
case _Stage.disabled:
return _message(
cs,
Symbols.nfc,
'Включите NFC в настройках телефона и попробуйте снова',
);
case _Stage.failed:
return _message(cs, Symbols.bluetooth_disabled, _failReason);
case _Stage.scanning:
return _scanning(cs);
case _Stage.exchanging:
return _exchanging(cs);
case _Stage.found:
case _Stage.adding:
case _Stage.added:
return _foundCard(cs);
}
}
Widget _message(ColorScheme cs, IconData icon, String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 28),
child: Column(
children: [
Icon(icon, color: cs.onSurfaceVariant, size: 44),
const SizedBox(height: 14),
Text(
text,
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
),
],
),
);
}
Widget _scanning(ColorScheme cs) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
children: [
SizedBox(
width: 180,
height: 180,
child: AnimatedBuilder(
animation: _pulse,
builder: (context, child) => CustomPaint(
painter: _RadarPainter(_pulse.value, cs.primary),
child: child,
),
child: Center(
child: Icon(Symbols.nfc, color: cs.primary, size: 48),
),
),
),
const SizedBox(height: 20),
Text(
'Поднесите телефоны друг к другу',
textAlign: TextAlign.center,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
'Оба устройства должны держать этот экран открытым',
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
),
);
}
Widget _exchanging(ColorScheme cs) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
children: [
SizedBox(
width: 120,
height: 120,
child: AnimatedBuilder(
animation: _pulse,
builder: (context, child) => CustomPaint(
painter: _RadarPainter(_pulse.value, cs.primary),
child: child,
),
child: Center(
child: Icon(Symbols.sync, color: cs.primary, size: 40),
),
),
),
const SizedBox(height: 22),
Text(
'Идёт обмен контактами…',
textAlign: TextAlign.center,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
'Почти готово',
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
),
);
}
Widget _foundCard(ColorScheme cs) {
final loading = _peerInfo == null && _stage == _Stage.found;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
SizedBox(
width: 160,
height: 160,
child: AnimatedBuilder(
animation: _reveal,
builder: (context, child) {
final t = _reveal.value;
final pop = Curves.elasticOut.transform(t.clamp(0.0, 1.0));
return CustomPaint(
painter: _BurstPainter(t, cs.primary),
child: Center(
child: Transform.scale(scale: pop, child: child),
),
);
},
child: KometAvatar(
name: _peerName(),
imageUrl: _peerInfo?['baseUrl'] as String?,
size: 92,
fontSize: 34,
),
),
),
const SizedBox(height: 14),
Text(
_peerName(),
textAlign: TextAlign.center,
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.w700,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
formatPhone(_peerPhone) ?? 'ID ${_peerId ?? ''}',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: (_stage == _Stage.adding || loading) ? null : _add,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: _stage == _Stage.adding
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(_stage == _Stage.added ? 'Добавлено' : 'Добавить контакт'),
),
),
],
),
);
}
}
class _RadarPainter extends CustomPainter {
final double progress;
final Color color;
_RadarPainter(this.progress, this.color);
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final maxRadius = size.width / 2;
for (var i = 0; i < 3; i++) {
final t = (progress + i / 3) % 1.0;
final radius = maxRadius * t;
final opacity = (1.0 - t) * 0.35;
if (opacity <= 0) continue;
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2
..color = color.withValues(alpha: opacity);
canvas.drawCircle(center, radius, paint);
}
final corePaint = Paint()
..color = color.withValues(alpha: 0.10 + 0.05 * math.sin(progress * 2 * math.pi));
canvas.drawCircle(center, maxRadius * 0.32, corePaint);
}
@override
bool shouldRepaint(_RadarPainter oldDelegate) =>
oldDelegate.progress != progress || oldDelegate.color != color;
}
class _BurstPainter extends CustomPainter {
final double progress;
final Color color;
_BurstPainter(this.progress, this.color);
@override
void paint(Canvas canvas, Size size) {
if (progress <= 0) return;
final center = Offset(size.width / 2, size.height / 2);
final maxRadius = size.width / 2;
final eased = Curves.easeOut.transform(progress.clamp(0.0, 1.0));
final glow = Paint()
..color = color.withValues(alpha: (1.0 - eased) * 0.18);
canvas.drawCircle(center, maxRadius * (0.45 + 0.55 * eased), glow);
for (var i = 0; i < 3; i++) {
final delay = i * 0.18;
final t = ((progress - delay) / (1.0 - delay)).clamp(0.0, 1.0);
if (t <= 0) continue;
final wave = Curves.easeOut.transform(t);
final radius = maxRadius * (0.3 + 0.7 * wave);
final opacity = (1.0 - wave) * 0.5;
if (opacity <= 0) continue;
final ring = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.5 * (1.0 - wave) + 0.5
..color = color.withValues(alpha: opacity);
canvas.drawCircle(center, radius, ring);
}
}
@override
bool shouldRepaint(_BurstPainter oldDelegate) =>
oldDelegate.progress != progress || oldDelegate.color != color;
}
+6
View File
@@ -25,6 +25,12 @@
"serverReconnectFailed": "Could not connect to the server",
"loginSignInWithQr": "Sign in with QR code",
"loginSignInWithToken": "Sign in with token",
"tokenLoginTitle": "Token login",
"tokenLoginTokenLabel": "Token",
"tokenLoginNote": "Token login only works with spoofing. Enter the data of the device the token belongs to, otherwise the account may be banned.",
"tokenLoginButton": "Sign in",
"tokenLoginError": "Fill in the token, device name, OS version and Device ID",
"tokenLoginFailed": "Sign in failed",
"loginSignInWithSessionFile": "Sign in with session file",
"loginLanguage": "Language",
"languageNameRu": "Русский",
+36
View File
@@ -248,6 +248,42 @@ abstract class AppLocalizations {
/// **'Sign in with token'**
String get loginSignInWithToken;
/// No description provided for @tokenLoginTitle.
///
/// In en, this message translates to:
/// **'Token login'**
String get tokenLoginTitle;
/// No description provided for @tokenLoginTokenLabel.
///
/// In en, this message translates to:
/// **'Token'**
String get tokenLoginTokenLabel;
/// No description provided for @tokenLoginNote.
///
/// In en, this message translates to:
/// **'Token login only works with spoofing. Enter the data of the device the token belongs to, otherwise the account may be banned.'**
String get tokenLoginNote;
/// No description provided for @tokenLoginButton.
///
/// In en, this message translates to:
/// **'Sign in'**
String get tokenLoginButton;
/// No description provided for @tokenLoginError.
///
/// In en, this message translates to:
/// **'Fill in the token, device name, OS version and Device ID'**
String get tokenLoginError;
/// No description provided for @tokenLoginFailed.
///
/// In en, this message translates to:
/// **'Sign in failed'**
String get tokenLoginFailed;
/// No description provided for @loginSignInWithSessionFile.
///
/// In en, this message translates to:
+20
View File
@@ -84,6 +84,26 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get loginSignInWithToken => 'Sign in with token';
@override
String get tokenLoginTitle => 'Token login';
@override
String get tokenLoginTokenLabel => 'Token';
@override
String get tokenLoginNote =>
'Token login only works with spoofing. Enter the data of the device the token belongs to, otherwise the account may be banned.';
@override
String get tokenLoginButton => 'Sign in';
@override
String get tokenLoginError =>
'Fill in the token, device name, OS version and Device ID';
@override
String get tokenLoginFailed => 'Sign in failed';
@override
String get loginSignInWithSessionFile => 'Sign in with session file';
+20
View File
@@ -86,6 +86,26 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get loginSignInWithToken => 'По токену';
@override
String get tokenLoginTitle => 'Вход по токену';
@override
String get tokenLoginTokenLabel => 'Токен';
@override
String get tokenLoginNote =>
'Вход по токену работает только со спуфом. Укажите данные устройства, к которому привязан токен, иначе аккаунт могут заблокировать.';
@override
String get tokenLoginButton => 'Войти';
@override
String get tokenLoginError =>
'Заполните токен, имя устройства, версию ОС и Device ID';
@override
String get tokenLoginFailed => 'Не удалось войти';
@override
String get loginSignInWithSessionFile => 'По файлу сессии';
+6
View File
@@ -25,6 +25,12 @@
"serverReconnectFailed": "Не удалось подключиться к серверу",
"loginSignInWithQr": "По QR code",
"loginSignInWithToken": "По токену",
"tokenLoginTitle": "Вход по токену",
"tokenLoginTokenLabel": "Токен",
"tokenLoginNote": "Вход по токену работает только со спуфом. Укажите данные устройства, к которому привязан токен, иначе аккаунт могут заблокировать.",
"tokenLoginButton": "Войти",
"tokenLoginError": "Заполните токен, имя устройства, версию ОС и Device ID",
"tokenLoginFailed": "Не удалось войти",
"loginSignInWithSessionFile": "По файлу сессии",
"loginLanguage": "Язык",
"languageNameRu": "Русский",