Merge pull request #36 from KometTeam/feature/nfc-contact-exchange
Feature/nfc contact exchange
This commit is contained in:
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": "Русский",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'По файлу сессии';
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
"serverReconnectFailed": "Не удалось подключиться к серверу",
|
||||
"loginSignInWithQr": "По QR code",
|
||||
"loginSignInWithToken": "По токену",
|
||||
"tokenLoginTitle": "Вход по токену",
|
||||
"tokenLoginTokenLabel": "Токен",
|
||||
"tokenLoginNote": "Вход по токену работает только со спуфом. Укажите данные устройства, к которому привязан токен, иначе аккаунт могут заблокировать.",
|
||||
"tokenLoginButton": "Войти",
|
||||
"tokenLoginError": "Заполните токен, имя устройства, версию ОС и Device ID",
|
||||
"tokenLoginFailed": "Не удалось войти",
|
||||
"loginSignInWithSessionFile": "По файлу сессии",
|
||||
"loginLanguage": "Язык",
|
||||
"languageNameRu": "Русский",
|
||||
|
||||
Reference in New Issue
Block a user