From d96499d88d89894e5ee512ed8f721683ac363d89 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 21 Jun 2026 07:49:15 +0000 Subject: [PATCH 1/9] =?UTF-8?q?feat(contacts):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20NFC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 13 + .../main/kotlin/ru/komet/app/MainActivity.kt | 150 ++++++++ .../main/kotlin/ru/komet/app/NfcExchange.kt | 43 +++ .../kotlin/ru/komet/app/NfcHostApduService.kt | 25 ++ android/app/src/main/res/values/strings.xml | 4 + .../app/src/main/res/xml/komet_nfc_apdu.xml | 9 + lib/backend/modules/contacts.dart | 47 +++ lib/core/nfc/nfc_exchange_service.dart | 67 ++++ .../screens/contacts/contacts_tab.dart | 21 +- .../screens/contacts/nfc_exchange_sheet.dart | 344 ++++++++++++++++++ 10 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt create mode 100644 android/app/src/main/kotlin/ru/komet/app/NfcHostApduService.kt create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/xml/komet_nfc_apdu.xml create mode 100644 lib/core/nfc/nfc_exchange_service.dart create mode 100644 lib/frontend/screens/contacts/nfc_exchange_sheet.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f453656..7396bc8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,8 @@ + + + + + + + + diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 89c4b6c..b9e3d56 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -8,15 +8,20 @@ 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 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 +29,22 @@ class MainActivity : FlutterActivity() { private val channelName = "ru.komet.app/vpn_bypass" private val iconAliases = listOf("DefaultIcon", "MinimalIcon") + private var nfcAdapter: NfcAdapter? = null + private var nfcEvents: EventChannel.EventSink? = null + private val nfcHandler = Handler(Looper.getMainLooper()) + private val nfcJitter = Random() + private val seenPeers = HashSet() + @Volatile private var nfcCycling = false + private val nfcReaderCallback = NfcAdapter.ReaderCallback { tag -> onNfcTagDiscovered(tag) } + private 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 + 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 +69,49 @@ 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 = when (val v = call.argument("selfId")) { + is Int -> v.toLong() + is Long -> v + else -> 0L + } + if (selfId <= 0L) { + result.error("INVALID_ID", "selfId must be positive", null) + } else { + startNfcExchange(selfId) + 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 +189,94 @@ class MainActivity : FlutterActivity() { } } + private fun nfcStatus(): Map { + val adapter = nfcAdapter + return mapOf( + "supported" to (adapter != null), + "enabled" to (adapter?.isEnabled == true), + ) + } + + private fun startNfcExchange(selfId: Long) { + NfcExchange.selfId = selfId + NfcExchange.active = true + seenPeers.clear() + nfcCycling = true + nfcHandler.removeCallbacksAndMessages(null) + nfcReaderOn() + } + + private fun stopNfcExchange() { + nfcCycling = false + NfcExchange.active = false + NfcExchange.selfId = 0L + nfcHandler.removeCallbacksAndMessages(null) + nfcReaderDisable() + } + + 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.parsePeerId(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 <= 0L) return + nfcHandler.post { + if (peer == NfcExchange.selfId || !seenPeers.add(peer)) return@post + nfcCycling = false + nfcReaderDisable() + nfcEvents?.success(mapOf("event" to "received", "id" to peer)) + } + } + + override fun onPause() { + super.onPause() + if (NfcExchange.active) { + stopNfcExchange() + nfcEvents?.success(mapOf("event" to "cancelled")) + } + } + private fun connectivityManager(): ConnectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager diff --git a/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt b/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt new file mode 100644 index 0000000..6146c3e --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt @@ -0,0 +1,43 @@ +package ru.komet.app + +object NfcExchange { + + const val AID = "F04B4F4D455431" + + private const val PREFIX = "KMT1:" + 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 + + fun buildSelectResponse(): ByteArray { + val id = selfId + if (!active || id <= 0L) return STATUS_NOT_FOUND + return (PREFIX + id).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 parsePeerId(response: ByteArray?): Long? { + if (response == null || response.size < 2) return null + val sw1 = response[response.size - 2] + val sw2 = response[response.size - 1] + if (sw1 != 0x90.toByte() || sw2.toInt() != 0x00) return null + val text = String(response.copyOfRange(0, response.size - 2), Charsets.UTF_8) + if (!text.startsWith(PREFIX)) return null + return text.substring(PREFIX.length).toLongOrNull() + } + + private fun hexToBytes(hex: String): ByteArray { + val out = ByteArray(hex.length / 2) + for (i in out.indices) { + out[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + return out + } +} diff --git a/android/app/src/main/kotlin/ru/komet/app/NfcHostApduService.kt b/android/app/src/main/kotlin/ru/komet/app/NfcHostApduService.kt new file mode 100644 index 0000000..e1668e4 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/NfcHostApduService.kt @@ -0,0 +1,25 @@ +package ru.komet.app + +import android.nfc.cardemulation.HostApduService +import android.os.Bundle + +class NfcHostApduService : HostApduService() { + + private val selectHeader = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00) + private val statusNotFound = byteArrayOf(0x6A, 0x82.toByte()) + + override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray { + if (commandApdu == null || !isSelectApdu(commandApdu)) return statusNotFound + return NfcExchange.buildSelectResponse() + } + + override fun onDeactivated(reason: Int) {} + + private fun isSelectApdu(apdu: ByteArray): Boolean { + if (apdu.size < selectHeader.size) return false + for (i in selectHeader.indices) { + if (apdu[i] != selectHeader[i]) return false + } + return true + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..55b2a48 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Komet contact exchange + Komet contact exchange + diff --git a/android/app/src/main/res/xml/komet_nfc_apdu.xml b/android/app/src/main/res/xml/komet_nfc_apdu.xml new file mode 100644 index 0000000..f484ffd --- /dev/null +++ b/android/app/src/main/res/xml/komet_nfc_apdu.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index ade6ad3..025d169 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -1,4 +1,8 @@ +import 'package:flutter/foundation.dart'; + +import '../../core/protocol/opcode_map.dart'; import '../../core/storage/app_database.dart'; +import '../api.dart'; import 'messages.dart'; class CachedContact { @@ -51,6 +55,49 @@ class CachedContact { } class ContactsModule { + static final ValueNotifier revision = ValueNotifier(0); + + static Future addContact( + Api api, + int id, + String firstName, + ) async { + final resp = await api.sendRequest(Opcode.contactUpdate, { + 'action': 'ADD', + 'contactId': id, + 'firstName': firstName, + }); + + final profile = await AppDatabase.loadActiveProfile(); + if (profile == null) return null; + + final data = resp.payload; + final contact = (data is Map && data['contact'] is Map) + ? (data['contact'] as Map).cast() + : null; + + final row = contact != null + ? _parseContact(contact, profile.id) + : { + 'id': id, + 'account_id': profile.id, + 'first_name': firstName, + 'last_name': null, + 'phone': 0, + 'photo_id': null, + 'base_url': null, + 'base_raw_url': null, + 'update_time': 0, + 'options': null, + }; + + if (row == null) return null; + await AppDatabase.saveContacts([row]); + if (contact != null) _primeContactCache(contact); + revision.value++; + return CachedContact.fromDbRow(row); + } + static Future syncFromLoginPayload( Map data, int accountId, diff --git a/lib/core/nfc/nfc_exchange_service.dart b/lib/core/nfc/nfc_exchange_service.dart new file mode 100644 index 0000000..a5b802d --- /dev/null +++ b/lib/core/nfc/nfc_exchange_service.dart @@ -0,0 +1,67 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; + +enum NfcEventType { received, cancelled } + +class NfcEvent { + final NfcEventType type; + final int? id; + + const NfcEvent(this.type, this.id); +} + +class NfcStatus { + final bool supported; + final bool enabled; + + const NfcStatus({required this.supported, required this.enabled}); + + bool get ready => supported && enabled; +} + +class NfcExchangeService { + NfcExchangeService._(); + static final NfcExchangeService instance = NfcExchangeService._(); + + static const MethodChannel _method = MethodChannel('ru.komet.app/nfc'); + static const EventChannel _events = EventChannel('ru.komet.app/nfc_events'); + + bool get _supported => Platform.isAndroid; + + Future status() async { + if (!_supported) return const NfcStatus(supported: false, enabled: false); + try { + final res = await _method.invokeMapMethod('status'); + return NfcStatus( + supported: res?['supported'] == true, + enabled: res?['enabled'] == true, + ); + } catch (_) { + return const NfcStatus(supported: false, enabled: false); + } + } + + Stream get events => + _events.receiveBroadcastStream().map(_decodeEvent); + + Future start(int selfId) => + _method.invokeMethod('start', {'selfId': selfId}); + + Future stop() async { + if (!_supported) return; + try { + await _method.invokeMethod('stop'); + } catch (_) {} + } + + NfcEvent _decodeEvent(dynamic raw) { + final map = raw is Map ? raw : const {}; + final id = map['id']; + final type = map['event'] == 'received' + ? NfcEventType.received + : NfcEventType.cancelled; + return NfcEvent(type, id is int ? id : (id is num ? id.toInt() : null)); + } +} diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 98566a5..55730a3 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -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,24 @@ class _ContactsTabState extends State { void initState() { super.initState(); _loadContacts(); + ContactsModule.revision.addListener(_loadContacts); + } + + @override + void dispose() { + ContactsModule.revision.removeListener(_loadContacts); + super.dispose(); + } + + Future _openNfcExchange() async { + final cs = Theme.of(context).colorScheme; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (_) => const NfcExchangeSheet(), + ); } Future _openSearchById() async { @@ -188,7 +207,7 @@ class _ContactsTabState extends State { ), IconButton( icon: Icon(Symbols.person_add, color: cs.onSurface), - onPressed: () {}, + onPressed: _openNfcExchange, ), IconButton( icon: Icon(Symbols.search, color: cs.onSurface), diff --git a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart new file mode 100644 index 0000000..1b13249 --- /dev/null +++ b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart @@ -0,0 +1,344 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.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 '../../../main.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/komet_avatar.dart'; + +enum _Stage { checking, unsupported, disabled, scanning, found, adding, added } + +class NfcExchangeSheet extends StatefulWidget { + const NfcExchangeSheet({super.key}); + + @override + State createState() => _NfcExchangeSheetState(); +} + +class _NfcExchangeSheetState extends State + with SingleTickerProviderStateMixin { + final _nfc = NfcExchangeService.instance; + late final AnimationController _pulse; + StreamSubscription? _sub; + + _Stage _stage = _Stage.checking; + int? _peerId; + Map? _peerInfo; + + @override + void initState() { + super.initState(); + _pulse = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + )..repeat(); + _begin(); + } + + @override + void dispose() { + _sub?.cancel(); + _nfc.stop(); + _pulse.dispose(); + super.dispose(); + } + + Future _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); + if (mounted) setState(() => _stage = _Stage.scanning); + } + + Future _onEvent(NfcEvent event) async { + if (event.type == NfcEventType.cancelled) { + if (mounted && _stage == _Stage.scanning) { + setState(() => _stage = _Stage.disabled); + } + return; + } + final id = event.id; + if (id == null || _peerId != null) return; + _peerId = id; + 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 _add() async { + final id = _peerId; + if (id == null) return; + setState(() => _stage = _Stage.adding); + try { + await ContactsModule.addContact(api, id, _firstNameForAdd()); + 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'); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 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), + _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.scanning: + return _scanning(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 _foundCard(ColorScheme cs) { + final loading = _peerInfo == null && _stage == _Stage.found; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + KometAvatar( + name: _peerName(), + imageUrl: _peerInfo?['baseUrl'] as String?, + size: 88, + 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( + '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; +} From f20a818825de1b9837f4315c8645f6c14a97cfad Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 21 Jun 2026 09:23:34 +0000 Subject: [PATCH 2/9] =?UTF-8?q?feat(auth):=20=D0=B2=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D1=83=20=D1=81=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC=20=D1=80=D1=83=D1=87=D0=BD=D1=8B=D0=BC=20=D1=81?= =?UTF-8?q?=D0=BF=D1=83=D1=84=D0=BE=D0=BC=20=D1=83=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 19 + lib/frontend/screens/auth/login_screen.dart | 9 + .../screens/auth/token_login_screen.dart | 337 ++++++++++++++++++ lib/l10n/app_en.arb | 6 + lib/l10n/app_localizations.dart | 36 ++ lib/l10n/app_localizations_en.dart | 20 ++ lib/l10n/app_localizations_ru.dart | 20 ++ lib/l10n/app_ru.arb | 6 + 8 files changed, 453 insertions(+) create mode 100644 lib/frontend/screens/auth/token_login_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 4b27c10..7822ab3 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1060,6 +1060,25 @@ class AccountModule { logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен'); } + Future loginWithToken(String token) async { + await TokenStorage.clearActiveAccount(); + try { + await _api.disconnect(); + } catch (_) {} + + ContactCache.clear(); + TranscriptionCache.clear(); + ChatsModule.resetForAccountSwitch(); + + await _api.connect(); + if (_api.state != SessionState.online) { + throw StateError('loginWithToken: нет соединения с сервером'); + } + + logger.i('Вход по токену: сессия поднята со спуфом, выполняю login'); + return login(token: token); + } + Future switchAccount(int accountId) async { final profile = await AppDatabase.loadProfile(accountId); if (profile == null) { diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 15b037d..ad18758 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -9,6 +9,7 @@ import 'package:komet/l10n/app_localizations.dart'; import 'package:komet/l10n/terms_of_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'code_confirmation_screen.dart'; +import 'token_login_screen.dart'; import 'select_country_screen.dart'; import 'proxy_settings_sheet.dart'; import 'server_settings_sheet.dart'; @@ -654,6 +655,14 @@ class _LoginScreenState extends State { ), onTap: () { Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => TokenLoginScreen( + returnToAccountId: widget.returnToAccountId, + ), + ), + ); }, ), ListTile( diff --git a/lib/frontend/screens/auth/token_login_screen.dart b/lib/frontend/screens/auth/token_login_screen.dart new file mode 100644 index 0000000..77ef864 --- /dev/null +++ b/lib/frontend/screens/auth/token_login_screen.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/storage/spoofing_service.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart'; +import '../../../models/spoof_profile.dart'; +import '../../widgets/adaptive_shell.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/section_header.dart'; + +class TokenLoginScreen extends StatefulWidget { + final int? returnToAccountId; + + const TokenLoginScreen({super.key, this.returnToAccountId}); + + @override + State createState() => _TokenLoginScreenState(); +} + +class _TokenLoginScreenState extends State { + final _tokenController = TextEditingController(); + final _deviceNameController = TextEditingController(); + final _osVersionController = TextEditingController(); + final _screenController = TextEditingController(); + final _timezoneController = TextEditingController(); + final _localeController = TextEditingController(); + final _deviceLocaleController = TextEditingController(); + final _deviceIdController = TextEditingController(); + final _appVersionController = TextEditingController( + text: SpoofingService.hardcodedAppVersion, + ); + final _buildNumberController = TextEditingController( + text: '${SpoofingService.hardcodedBuildNumber}', + ); + final _pushDeviceTypeController = TextEditingController(text: 'GCM'); + final _instanceIdController = TextEditingController(); + final _clientSessionIdController = TextEditingController(); + final _userAgentController = TextEditingController(); + + String _selectedDeviceType = 'ANDROID'; + String _selectedArch = 'arm64-v8a'; + bool _isLoading = false; + + @override + void dispose() { + _tokenController.dispose(); + _deviceNameController.dispose(); + _osVersionController.dispose(); + _screenController.dispose(); + _timezoneController.dispose(); + _localeController.dispose(); + _deviceLocaleController.dispose(); + _deviceIdController.dispose(); + _appVersionController.dispose(); + _buildNumberController.dispose(); + _pushDeviceTypeController.dispose(); + _instanceIdController.dispose(); + _clientSessionIdController.dispose(); + _userAgentController.dispose(); + super.dispose(); + } + + bool get _isValid => + _tokenController.text.trim().isNotEmpty && + _deviceNameController.text.trim().isNotEmpty && + _osVersionController.text.trim().isNotEmpty && + _deviceIdController.text.trim().isNotEmpty; + + Future _login() async { + final l10n = AppLocalizations.of(context)!; + if (!_isValid) { + showCustomNotification(context, l10n.tokenLoginError); + return; + } + + setState(() => _isLoading = true); + + final profile = SpoofProfile( + enabled: true, + deviceName: _deviceNameController.text.trim(), + osVersion: _osVersionController.text.trim(), + screen: _screenController.text.trim(), + timezone: _timezoneController.text.trim(), + locale: _localeController.text.trim(), + deviceLocale: _deviceLocaleController.text.trim(), + deviceId: _deviceIdController.text.trim(), + deviceType: _selectedDeviceType, + arch: _selectedArch, + appVersion: _appVersionController.text.trim(), + buildNumber: int.tryParse(_buildNumberController.text.trim()) ?? + SpoofingService.hardcodedBuildNumber, + pushDeviceType: _pushDeviceTypeController.text.trim(), + instanceId: _instanceIdController.text.trim(), + clientSessionId: int.tryParse(_clientSessionIdController.text.trim()), + userAgent: _userAgentController.text.trim(), + ); + + try { + await SpoofingService.saveProfile(SpoofingService.pendingScope, profile); + await accountModule.loginWithToken(_tokenController.text.trim()); + if (!mounted) return; + await Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const AdaptiveShell()), + (route) => false, + ); + } catch (e) { + if (!mounted) return; + setState(() => _isLoading = false); + showCustomNotification(context, '${l10n.tokenLoginFailed}: $e'); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Scaffold( + appBar: AppBar(title: Text(l10n.tokenLoginTitle), centerTitle: true), + body: AbsorbPointer( + absorbing: _isLoading, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 120), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildNoteCard(l10n), + const SizedBox(height: 16), + _buildTokenCard(l10n), + const SizedBox(height: 16), + _buildDeviceCard(l10n), + ], + ), + ), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FilledButton( + onPressed: _isLoading ? null : _login, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: const StadiumBorder(), + ), + child: _isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.tokenLoginButton), + ), + ), + ); + } + + Widget _buildNoteCard(AppLocalizations l10n) { + final cs = Theme.of(context).colorScheme; + return Card( + color: cs.secondaryContainer.withValues(alpha: 0.5), + elevation: 0, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(Symbols.warning, size: 20, color: cs.onSecondaryContainer), + const SizedBox(width: 8), + Flexible( + child: Text( + l10n.tokenLoginNote, + style: TextStyle(fontSize: 13, color: cs.onSecondaryContainer), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTokenCard(AppLocalizations l10n) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _tokenController, + minLines: 1, + maxLines: 3, + onChanged: (_) => setState(() {}), + decoration: _decoration(l10n.tokenLoginTokenLabel, Symbols.key), + ), + ), + ); + } + + Widget _buildDeviceCard(AppLocalizations l10n) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader( + l10n.spoofMainSectionTitle, + padding: const EdgeInsets.only(bottom: 16, top: 4), + fontSize: 20, + ), + Text(l10n.spoofDeviceTypeTitle), + const SizedBox(height: 8), + _chips( + const [ + _Opt('ANDROID', 'Android', Symbols.android), + _Opt('IOS', 'iOS', Symbols.phone_iphone), + ], + _selectedDeviceType, + (v) => setState(() => _selectedDeviceType = v), + ), + const SizedBox(height: 16), + _field(_deviceNameController, l10n.spoofFieldDeviceName, + Symbols.smartphone), + const SizedBox(height: 16), + _field(_osVersionController, l10n.spoofFieldOsVersion, + Symbols.layers), + const SizedBox(height: 16), + _field(_screenController, l10n.spoofFieldScreen, Symbols.fullscreen), + const SizedBox(height: 16), + _field(_timezoneController, l10n.spoofFieldTimezone, Symbols.public), + const SizedBox(height: 16), + _field(_localeController, l10n.spoofFieldLocale, Symbols.language), + const SizedBox(height: 16), + _field(_deviceLocaleController, l10n.spoofFieldDeviceLocale, + Symbols.translate), + const SizedBox(height: 24), + SectionHeader( + l10n.spoofIdentifiersSectionTitle, + padding: const EdgeInsets.only(bottom: 16, top: 4), + fontSize: 20, + ), + _field(_deviceIdController, l10n.spoofFieldDeviceId, Symbols.tag, + onChanged: true), + const SizedBox(height: 16), + _field(_instanceIdController, l10n.spoofFieldInstanceId, + Symbols.fingerprint), + const SizedBox(height: 16), + _field(_clientSessionIdController, l10n.spoofFieldClientSessionId, + Symbols.vpn_key, + number: true), + const SizedBox(height: 16), + _field(_appVersionController, l10n.spoofFieldAppVersion, + Symbols.info), + const SizedBox(height: 16), + _field(_buildNumberController, l10n.spoofFieldBuildNumber, + Symbols.numbers, + number: true), + const SizedBox(height: 16), + _field(_pushDeviceTypeController, l10n.spoofFieldPushDeviceType, + Symbols.notifications), + const SizedBox(height: 16), + Text(l10n.spoofFieldArchitecture), + const SizedBox(height: 8), + _chips( + const [ + _Opt('arm64-v8a', 'arm64-v8a', Symbols.memory), + _Opt('armeabi-v7a', 'armeabi-v7a', Symbols.memory), + _Opt('arm64', 'arm64', Symbols.memory), + _Opt('x86_64', 'x86_64', Symbols.memory), + _Opt('x86', 'x86', Symbols.memory), + ], + _selectedArch, + (v) => setState(() => _selectedArch = v), + ), + ], + ), + ), + ); + } + + Widget _field( + TextEditingController controller, + String label, + IconData icon, { + bool number = false, + bool onChanged = false, + }) { + return TextField( + controller: controller, + keyboardType: number ? TextInputType.number : null, + onChanged: onChanged ? (_) => setState(() {}) : null, + decoration: _decoration(label, icon), + ); + } + + InputDecoration _decoration(String label, IconData icon) { + return InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, + ); + } + + Widget _chips( + List<_Opt> options, + String selected, + ValueChanged onSelected, + ) { + final cs = Theme.of(context).colorScheme; + return Wrap( + spacing: 8, + runSpacing: 8, + children: options.map((opt) { + final isSelected = opt.value == selected; + return ChoiceChip( + label: Text(opt.label), + avatar: isSelected + ? Icon(Icons.check, size: 18, color: cs.onSecondaryContainer) + : Icon(opt.icon, size: 18, color: cs.onSurfaceVariant), + selected: isSelected, + showCheckmark: false, + onSelected: (_) => onSelected(opt.value), + backgroundColor: cs.surfaceContainerHighest, + selectedColor: cs.secondaryContainer, + side: BorderSide( + color: isSelected ? Colors.transparent : cs.outlineVariant, + ), + ); + }).toList(), + ); + } +} + +class _Opt { + final String value; + final String label; + final IconData icon; + + const _Opt(this.value, this.label, this.icon); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b7323e5..e00dab0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": "Русский", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6f8ded8..fe413a5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index edd8f07..d7de50c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index b6a58ca..12a2e88 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'По файлу сессии'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 8e15e71..9bba889 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -25,6 +25,12 @@ "serverReconnectFailed": "Не удалось подключиться к серверу", "loginSignInWithQr": "По QR code", "loginSignInWithToken": "По токену", + "tokenLoginTitle": "Вход по токену", + "tokenLoginTokenLabel": "Токен", + "tokenLoginNote": "Вход по токену работает только со спуфом. Укажите данные устройства, к которому привязан токен, иначе аккаунт могут заблокировать.", + "tokenLoginButton": "Войти", + "tokenLoginError": "Заполните токен, имя устройства, версию ОС и Device ID", + "tokenLoginFailed": "Не удалось войти", "loginSignInWithSessionFile": "По файлу сессии", "loginLanguage": "Язык", "languageNameRu": "Русский", From 6b082a81fe4cfa092aa5c4ded2fb9b198f4e81e8 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 21 Jun 2026 10:18:48 +0000 Subject: [PATCH 3/9] =?UTF-8?q?feat(contacts):=20=D0=BE=D0=B1=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B0=D0=BA=D1=82=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20NFC=E2=86=92BLE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 4 + .../kotlin/ru/komet/app/BleContactExchange.kt | 342 ++++++++++++++++++ .../main/kotlin/ru/komet/app/MainActivity.kt | 95 ++++- .../main/kotlin/ru/komet/app/NfcExchange.kt | 22 +- lib/core/nfc/nfc_exchange_service.dart | 18 +- .../screens/contacts/nfc_exchange_sheet.dart | 25 +- 6 files changed, 490 insertions(+), 16 deletions(-) create mode 100644 android/app/src/main/kotlin/ru/komet/app/BleContactExchange.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7396bc8..cc7840c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + @@ -17,6 +18,9 @@ + + + Unit)? = null + var onError: ((String) -> Unit)? = null + + private val main = Handler(Looper.getMainLooper()) + + private val manager: BluetoothManager? = + context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + private val adapter: BluetoothAdapter? = manager?.adapter + + private var gattServer: BluetoothGattServer? = null + private var advertiser: BluetoothLeAdvertiser? = null + private var scanner: BluetoothLeScanner? = null + private var scanCallback: ScanCallback? = null + private var advertiseCallback: AdvertiseCallback? = null + private var clientGatt: BluetoothGatt? = null + + @Volatile private var selfId: Long = 0L + @Volatile private var selfSession: String = "" + @Volatile private var connecting = false + @Volatile private var running = false + + fun start(selfId: Long, selfSession: String) { + val adapter = this.adapter + if (adapter == null || !adapter.isEnabled) { + emitError("bluetooth_off") + return + } + this.selfId = selfId + this.selfSession = selfSession + running = true + startGattServer() + } + + fun connectTo(peerSession: String) { + if (!running || connecting) return + startScan(peerSession) + } + + fun stop() { + running = false + connecting = false + stopScan() + stopAdvertising() + try { + clientGatt?.disconnect() + clientGatt?.close() + } catch (e: Exception) { + Log.w(LOG_TAG, "client close: ${e.message}") + } + clientGatt = null + try { + gattServer?.close() + } catch (e: Exception) { + Log.w(LOG_TAG, "server close: ${e.message}") + } + gattServer = null + } + + private fun startGattServer() { + val server = try { + manager?.openGattServer(context, serverCallback) + } catch (e: SecurityException) { + emitError("permission") + return + } + if (server == null) { + emitError("gatt_unavailable") + return + } + gattServer = server + val characteristic = BluetoothGattCharacteristic( + CHAR_UUID, + BluetoothGattCharacteristic.PROPERTY_WRITE or + BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, + BluetoothGattCharacteristic.PERMISSION_WRITE, + ) + val service = BluetoothGattService( + SERVICE_UUID, + BluetoothGattService.SERVICE_TYPE_PRIMARY, + ) + service.addCharacteristic(characteristic) + try { + server.addService(service) + } catch (e: SecurityException) { + emitError("permission") + } + } + + private fun startAdvertising() { + val advertiser = adapter?.bluetoothLeAdvertiser + if (advertiser == null) { + Log.w(LOG_TAG, "advertising unsupported on this device") + return + } + this.advertiser = advertiser + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) + .setConnectable(true) + .build() + val data = AdvertiseData.Builder() + .setIncludeDeviceName(false) + .setIncludeTxPowerLevel(false) + .addServiceUuid(ParcelUuid(SERVICE_UUID)) + .addManufacturerData(MFG_ID, hexToBytes(selfSession)) + .build() + val callback = object : AdvertiseCallback() { + override fun onStartFailure(errorCode: Int) { + Log.w(LOG_TAG, "advertise failed: $errorCode") + } + } + advertiseCallback = callback + try { + advertiser.startAdvertising(settings, data, callback) + } catch (e: SecurityException) { + emitError("permission") + } + } + + private fun stopAdvertising() { + val callback = advertiseCallback ?: return + try { + advertiser?.stopAdvertising(callback) + } catch (e: Exception) { + Log.w(LOG_TAG, "stopAdvertising: ${e.message}") + } + advertiseCallback = null + } + + private fun startScan(peerSession: String) { + val scanner = adapter?.bluetoothLeScanner + if (scanner == null) { + emitError("scan_unavailable") + return + } + this.scanner = scanner + val target = peerSession.lowercase() + val filters = listOf( + ScanFilter.Builder() + .setServiceUuid(ParcelUuid(SERVICE_UUID)) + .build(), + ) + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult?) { + handleScanResult(result, target) + } + + override fun onBatchScanResults(results: MutableList?) { + results?.forEach { handleScanResult(it, target) } + } + + override fun onScanFailed(errorCode: Int) { + Log.w(LOG_TAG, "scan failed: $errorCode") + emitError("scan_failed") + } + } + scanCallback = callback + try { + scanner.startScan(filters, settings, callback) + } catch (e: SecurityException) { + emitError("permission") + } + } + + private fun stopScan() { + val callback = scanCallback ?: return + try { + scanner?.stopScan(callback) + } catch (e: Exception) { + Log.w(LOG_TAG, "stopScan: ${e.message}") + } + scanCallback = null + } + + private fun handleScanResult(result: ScanResult?, target: String) { + if (result == null || connecting) return + val mfg = result.scanRecord?.getManufacturerSpecificData(MFG_ID) ?: return + if (bytesToHex(mfg).lowercase() != target) return + connecting = true + stopScan() + connectGatt(result.device) + } + + private fun connectGatt(device: BluetoothDevice) { + try { + clientGatt = device.connectGatt( + context, + false, + clientCallback, + BluetoothDevice.TRANSPORT_LE, + ) + } catch (e: SecurityException) { + connecting = false + emitError("permission") + } + } + + private val serverCallback = object : BluetoothGattServerCallback() { + override fun onServiceAdded(status: Int, service: BluetoothGattService?) { + if (running) startAdvertising() + } + + override fun onCharacteristicWriteRequest( + device: BluetoothDevice?, + requestId: Int, + characteristic: BluetoothGattCharacteristic?, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray?, + ) { + if (responseNeeded) { + try { + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + offset, + null, + ) + } catch (e: SecurityException) { + Log.w(LOG_TAG, "sendResponse: ${e.message}") + } + } + if (characteristic?.uuid != CHAR_UUID || value == null) return + val peerId = String(value, Charsets.UTF_8).trim().toLongOrNull() ?: return + if (peerId > 0L) emitReceived(peerId) + } + } + + private val clientCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + try { + gatt?.discoverServices() + } catch (e: SecurityException) { + Log.w(LOG_TAG, "discoverServices: ${e.message}") + } + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + try { + gatt?.close() + } catch (e: Exception) { + Log.w(LOG_TAG, "gatt close: ${e.message}") + } + if (gatt == clientGatt) clientGatt = null + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { + if (gatt == null || status != BluetoothGatt.GATT_SUCCESS) { + gatt?.disconnect() + return + } + val characteristic = gatt.getService(SERVICE_UUID)?.getCharacteristic(CHAR_UUID) + if (characteristic == null) { + gatt.disconnect() + return + } + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + @Suppress("DEPRECATION") + characteristic.value = selfId.toString().toByteArray(Charsets.UTF_8) + try { + @Suppress("DEPRECATION") + gatt.writeCharacteristic(characteristic) + } catch (e: SecurityException) { + Log.w(LOG_TAG, "writeCharacteristic: ${e.message}") + gatt.disconnect() + } + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt?, + characteristic: BluetoothGattCharacteristic?, + status: Int, + ) { + gatt?.disconnect() + } + } + + private fun emitReceived(id: Long) { + main.post { onReceived?.invoke(id) } + } + + private fun emitError(reason: String) { + main.post { onError?.invoke(reason) } + } + + private fun hexToBytes(hex: String): ByteArray { + val clean = if (hex.length % 2 == 0) hex else "0$hex" + val out = ByteArray(clean.length / 2) + for (i in out.indices) { + out[i] = clean.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + return out + } + + private fun bytesToHex(bytes: ByteArray): String { + val sb = StringBuilder(bytes.size * 2) + for (b in bytes) sb.append("%02x".format(b)) + return sb.toString() + } +} diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index b9e3d56..7cc3421 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -1,5 +1,6 @@ package ru.komet.app +import android.Manifest import android.content.ComponentName import android.content.Context import android.content.Intent @@ -15,6 +16,8 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel @@ -37,11 +40,16 @@ class MainActivity : FlutterActivity() { @Volatile private var nfcCycling = false private val nfcReaderCallback = NfcAdapter.ReaderCallback { tag -> onNfcTagDiscovered(tag) } + private var ble: BleContactExchange? = null + private var pendingSelfId = 0L + private var pendingSession = "" + private companion object { const val LOG_TAG = "VpnBypass" const val NFC_TAG = "NfcExchange" const val NFC_PHASE_MIN_MS = 350L const val NFC_PHASE_JITTER_MS = 400 + const val BLE_PERMS_REQUEST = 7711 val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK @@ -198,20 +206,96 @@ class MainActivity : FlutterActivity() { } private fun startNfcExchange(selfId: Long) { + val session = "%08x".format(nfcJitter.nextInt()) NfcExchange.selfId = selfId + NfcExchange.selfSession = session NfcExchange.active = true + NfcExchange.onServed = { onNfcServed() } seenPeers.clear() + pendingSelfId = selfId + pendingSession = session nfcCycling = true nfcHandler.removeCallbacksAndMessages(null) nfcReaderOn() + ensureBleStarted() } private fun stopNfcExchange() { nfcCycling = false NfcExchange.active = false NfcExchange.selfId = 0L + NfcExchange.selfSession = "" + NfcExchange.onServed = null nfcHandler.removeCallbacksAndMessages(null) nfcReaderDisable() + ble?.stop() + } + + private fun blePermissions(): Array = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + arrayOf( + Manifest.permission.BLUETOOTH_ADVERTISE, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + ) + } else { + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + + private fun hasBlePermissions(): Boolean = blePermissions().all { + ContextCompat.checkSelfPermission(this, it) == + PackageManager.PERMISSION_GRANTED + } + + private fun ensureBleStarted() { + if (hasBlePermissions()) { + startBle() + } else { + ActivityCompat.requestPermissions(this, blePermissions(), BLE_PERMS_REQUEST) + } + } + + private fun startBle() { + val exchange = ble ?: BleContactExchange(applicationContext).also { + it.onReceived = { id -> onBleReceived(id) } + it.onError = { reason -> onBleError(reason) } + ble = it + } + exchange.start(pendingSelfId, pendingSession) + } + + private fun onBleReceived(id: Long) { + if (id == NfcExchange.selfId || !seenPeers.add(id)) return + nfcEvents?.success(mapOf("event" to "received", "id" to id)) + } + + private fun onBleError(reason: String) { + nfcEvents?.success(mapOf("event" to "error", "reason" to reason)) + } + + private fun onNfcServed() { + nfcHandler.post { + if (!NfcExchange.active) return@post + nfcCycling = false + nfcReaderDisable() + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode != BLE_PERMS_REQUEST) return + if (!NfcExchange.active) return + val granted = grantResults.isNotEmpty() && + grantResults.all { it == PackageManager.PERMISSION_GRANTED } + if (granted) { + startBle() + } else { + onBleError("permission") + } } private fun nfcReaderOn() { @@ -250,7 +334,7 @@ class MainActivity : FlutterActivity() { val isoDep = IsoDep.get(tag) ?: return val peer = try { isoDep.connect() - NfcExchange.parsePeerId(isoDep.transceive(NfcExchange.buildSelectCommand())) + NfcExchange.parsePeer(isoDep.transceive(NfcExchange.buildSelectCommand())) } catch (e: Exception) { Log.w(NFC_TAG, "transceive failed: ${e.message}") null @@ -260,12 +344,15 @@ class MainActivity : FlutterActivity() { } catch (_: Exception) { } } - if (peer == null || peer <= 0L) return + if (peer == null || peer.id <= 0L) return nfcHandler.post { - if (peer == NfcExchange.selfId || !seenPeers.add(peer)) return@post + if (peer.id == NfcExchange.selfId) return@post nfcCycling = false nfcReaderDisable() - nfcEvents?.success(mapOf("event" to "received", "id" to peer)) + ble?.connectTo(peer.session) + if (seenPeers.add(peer.id)) { + nfcEvents?.success(mapOf("event" to "received", "id" to peer.id)) + } } } diff --git a/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt b/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt index 6146c3e..9bf557f 100644 --- a/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt +++ b/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt @@ -4,17 +4,24 @@ object NfcExchange { const val AID = "F04B4F4D455431" - private const val PREFIX = "KMT1:" + private const val PREFIX = "KMT2:" private val STATUS_OK = byteArrayOf(0x90.toByte(), 0x00) private val STATUS_NOT_FOUND = byteArrayOf(0x6A, 0x82.toByte()) @Volatile var active: Boolean = false @Volatile var selfId: Long = 0L + @Volatile var selfSession: String = "" + + @Volatile var onServed: (() -> Unit)? = null + + data class Peer(val id: Long, val session: String) fun buildSelectResponse(): ByteArray { val id = selfId - if (!active || id <= 0L) return STATUS_NOT_FOUND - return (PREFIX + id).toByteArray(Charsets.UTF_8) + STATUS_OK + val session = selfSession + if (!active || id <= 0L || session.isEmpty()) return STATUS_NOT_FOUND + onServed?.invoke() + return (PREFIX + id + ":" + session).toByteArray(Charsets.UTF_8) + STATUS_OK } fun buildSelectCommand(): ByteArray { @@ -23,14 +30,19 @@ object NfcExchange { aid + byteArrayOf(0x00) } - fun parsePeerId(response: ByteArray?): Long? { + fun parsePeer(response: ByteArray?): Peer? { if (response == null || response.size < 2) return null val sw1 = response[response.size - 2] val sw2 = response[response.size - 1] if (sw1 != 0x90.toByte() || sw2.toInt() != 0x00) return null val text = String(response.copyOfRange(0, response.size - 2), Charsets.UTF_8) if (!text.startsWith(PREFIX)) return null - return text.substring(PREFIX.length).toLongOrNull() + val parts = text.substring(PREFIX.length).split(":") + if (parts.size < 2) return null + val id = parts[0].toLongOrNull() ?: return null + val session = parts[1] + if (session.isEmpty()) return null + return Peer(id, session) } private fun hexToBytes(hex: String): ByteArray { diff --git a/lib/core/nfc/nfc_exchange_service.dart b/lib/core/nfc/nfc_exchange_service.dart index a5b802d..f5af0e9 100644 --- a/lib/core/nfc/nfc_exchange_service.dart +++ b/lib/core/nfc/nfc_exchange_service.dart @@ -3,13 +3,14 @@ import 'dart:io'; import 'package:flutter/services.dart'; -enum NfcEventType { received, cancelled } +enum NfcEventType { received, cancelled, error } class NfcEvent { final NfcEventType type; final int? id; + final String? reason; - const NfcEvent(this.type, this.id); + const NfcEvent(this.type, this.id, {this.reason}); } class NfcStatus { @@ -59,9 +60,14 @@ class NfcExchangeService { NfcEvent _decodeEvent(dynamic raw) { final map = raw is Map ? raw : const {}; final id = map['id']; - final type = map['event'] == 'received' - ? NfcEventType.received - : NfcEventType.cancelled; - return NfcEvent(type, id is int ? id : (id is num ? id.toInt() : null)); + final parsedId = id is int ? id : (id is num ? id.toInt() : null); + switch (map['event']) { + case 'received': + return NfcEvent(NfcEventType.received, parsedId); + case 'error': + return NfcEvent(NfcEventType.error, null, reason: map['reason'] as String?); + default: + return const NfcEvent(NfcEventType.cancelled, null); + } } } diff --git a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart index 1b13249..529c03c 100644 --- a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart +++ b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart @@ -12,7 +12,7 @@ import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; -enum _Stage { checking, unsupported, disabled, scanning, found, adding, added } +enum _Stage { checking, unsupported, disabled, failed, scanning, found, adding, added } class NfcExchangeSheet extends StatefulWidget { const NfcExchangeSheet({super.key}); @@ -30,6 +30,7 @@ class _NfcExchangeSheetState extends State _Stage _stage = _Stage.checking; int? _peerId; Map? _peerInfo; + String _failReason = ''; @override void initState() { @@ -78,6 +79,15 @@ class _NfcExchangeSheetState extends State } return; } + if (event.type == NfcEventType.error) { + if (mounted && _peerId == null) { + setState(() { + _failReason = _reasonText(event.reason); + _stage = _Stage.failed; + }); + } + return; + } final id = event.id; if (id == null || _peerId != null) return; _peerId = id; @@ -141,6 +151,17 @@ class _NfcExchangeSheetState extends State } } + String _reasonText(String? reason) { + switch (reason) { + case 'bluetooth_off': + return 'Включите Bluetooth и попробуйте снова'; + case 'permission': + return 'Нужны разрешения Bluetooth для обмена'; + default: + return 'Не удалось установить соединение'; + } + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -191,6 +212,8 @@ class _NfcExchangeSheetState extends State Symbols.nfc, 'Включите NFC в настройках телефона и попробуйте снова', ); + case _Stage.failed: + return _message(cs, Symbols.bluetooth_disabled, _failReason); case _Stage.scanning: return _scanning(cs); case _Stage.found: From eb20ed025f57167e20bc9fc680a152401e035d99 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 21 Jun 2026 10:47:59 +0000 Subject: [PATCH 4/9] =?UTF-8?q?feat(contacts):=20=D1=81=D0=B8=D0=BD=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20+?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D0=BE=D0=B1=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/ru/komet/app/BleContactExchange.kt | 13 +++- .../main/kotlin/ru/komet/app/MainActivity.kt | 17 ++++- lib/core/nfc/nfc_exchange_service.dart | 4 +- .../screens/contacts/nfc_exchange_sheet.dart | 74 ++++++++++++++++++- 4 files changed, 100 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/kotlin/ru/komet/app/BleContactExchange.kt b/android/app/src/main/kotlin/ru/komet/app/BleContactExchange.kt index c2cace1..4a4140f 100644 --- a/android/app/src/main/kotlin/ru/komet/app/BleContactExchange.kt +++ b/android/app/src/main/kotlin/ru/komet/app/BleContactExchange.kt @@ -36,6 +36,7 @@ class BleContactExchange(private val context: Context) { } var onReceived: ((Long) -> Unit)? = null + var onSent: ((Long) -> Unit)? = null var onError: ((String) -> Unit)? = null private val main = Handler(Looper.getMainLooper()) @@ -53,6 +54,7 @@ class BleContactExchange(private val context: Context) { @Volatile private var selfId: Long = 0L @Volatile private var selfSession: String = "" + @Volatile private var peerIdForWrite: Long = 0L @Volatile private var connecting = false @Volatile private var running = false @@ -68,8 +70,9 @@ class BleContactExchange(private val context: Context) { startGattServer() } - fun connectTo(peerSession: String) { + fun connectTo(peerSession: String, peerId: Long) { if (!running || connecting) return + peerIdForWrite = peerId startScan(peerSession) } @@ -313,6 +316,10 @@ class BleContactExchange(private val context: Context) { characteristic: BluetoothGattCharacteristic?, status: Int, ) { + if (status == BluetoothGatt.GATT_SUCCESS) { + val id = peerIdForWrite + if (id > 0L) emitSent(id) + } gatt?.disconnect() } } @@ -321,6 +328,10 @@ class BleContactExchange(private val context: Context) { main.post { onReceived?.invoke(id) } } + private fun emitSent(id: Long) { + main.post { onSent?.invoke(id) } + } + private fun emitError(reason: String) { main.post { onError?.invoke(reason) } } diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 7cc3421..4b39664 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -43,6 +43,7 @@ class MainActivity : FlutterActivity() { private var ble: BleContactExchange? = null private var pendingSelfId = 0L private var pendingSession = "" + @Volatile private var exchangingEmitted = false private companion object { const val LOG_TAG = "VpnBypass" @@ -212,6 +213,7 @@ class MainActivity : FlutterActivity() { NfcExchange.active = true NfcExchange.onServed = { onNfcServed() } seenPeers.clear() + exchangingEmitted = false pendingSelfId = selfId pendingSession = session nfcCycling = true @@ -258,12 +260,19 @@ class MainActivity : FlutterActivity() { private fun startBle() { val exchange = ble ?: BleContactExchange(applicationContext).also { it.onReceived = { id -> onBleReceived(id) } + it.onSent = { id -> onBleReceived(id) } it.onError = { reason -> onBleError(reason) } ble = it } exchange.start(pendingSelfId, pendingSession) } + private fun emitExchanging() { + if (exchangingEmitted) return + exchangingEmitted = true + nfcEvents?.success(mapOf("event" to "exchanging")) + } + private fun onBleReceived(id: Long) { if (id == NfcExchange.selfId || !seenPeers.add(id)) return nfcEvents?.success(mapOf("event" to "received", "id" to id)) @@ -278,6 +287,7 @@ class MainActivity : FlutterActivity() { if (!NfcExchange.active) return@post nfcCycling = false nfcReaderDisable() + emitExchanging() } } @@ -349,10 +359,9 @@ class MainActivity : FlutterActivity() { if (peer.id == NfcExchange.selfId) return@post nfcCycling = false nfcReaderDisable() - ble?.connectTo(peer.session) - if (seenPeers.add(peer.id)) { - nfcEvents?.success(mapOf("event" to "received", "id" to peer.id)) - } + emitExchanging() + ble?.connectTo(peer.session, peer.id) + nfcHandler.postDelayed({ onBleReceived(peer.id) }, 3000L) } } diff --git a/lib/core/nfc/nfc_exchange_service.dart b/lib/core/nfc/nfc_exchange_service.dart index f5af0e9..f7ea9af 100644 --- a/lib/core/nfc/nfc_exchange_service.dart +++ b/lib/core/nfc/nfc_exchange_service.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:flutter/services.dart'; -enum NfcEventType { received, cancelled, error } +enum NfcEventType { received, exchanging, cancelled, error } class NfcEvent { final NfcEventType type; @@ -64,6 +64,8 @@ class NfcExchangeService { switch (map['event']) { case 'received': return NfcEvent(NfcEventType.received, parsedId); + case 'exchanging': + return const NfcEvent(NfcEventType.exchanging, null); case 'error': return NfcEvent(NfcEventType.error, null, reason: map['reason'] as String?); default: diff --git a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart index 529c03c..5ff5d7a 100644 --- a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart +++ b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart @@ -12,7 +12,17 @@ import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; -enum _Stage { checking, unsupported, disabled, failed, scanning, found, adding, added } +enum _Stage { + checking, + unsupported, + disabled, + failed, + scanning, + exchanging, + found, + adding, + added, +} class NfcExchangeSheet extends StatefulWidget { const NfcExchangeSheet({super.key}); @@ -88,6 +98,12 @@ class _NfcExchangeSheetState extends State } 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; @@ -190,7 +206,19 @@ class _NfcExchangeSheetState extends State ], ), const SizedBox(height: 12), - _buildContent(cs), + 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), + ), + ), ], ), ), @@ -216,6 +244,8 @@ class _NfcExchangeSheetState extends State 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: @@ -280,6 +310,46 @@ class _NfcExchangeSheetState extends State ); } + 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( From 2f3b280fa4e8bd199e00410c6e16f5122b365d8e Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 21 Jun 2026 11:43:28 +0000 Subject: [PATCH 5/9] =?UTF-8?q?feat(contacts):=20=D0=BE=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=20=D0=BE=D0=B1=D0=BC=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=85=D1=83=20+=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B0=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/contacts/contacts_tab.dart | 25 ++++- .../screens/contacts/nfc_exchange_sheet.dart | 103 +++++++++++++++--- 2 files changed, 108 insertions(+), 20 deletions(-) diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 55730a3..cf0ad73 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -36,13 +36,26 @@ class _ContactsTabState extends State { } Future _openNfcExchange() async { - final cs = Theme.of(context).colorScheme; - await showModalBottomSheet( + await showGeneralDialog( context: context, - isScrollControlled: true, - backgroundColor: cs.surfaceContainerHigh, - shape: kSheetShape, - builder: (_) => const NfcExchangeSheet(), + 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, + ); + }, ); } diff --git a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart index 5ff5d7a..0520876 100644 --- a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart +++ b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart @@ -2,6 +2,7 @@ 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'; @@ -32,9 +33,10 @@ class NfcExchangeSheet extends StatefulWidget { } class _NfcExchangeSheetState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { final _nfc = NfcExchangeService.instance; late final AnimationController _pulse; + late final AnimationController _reveal; StreamSubscription? _sub; _Stage _stage = _Stage.checking; @@ -49,6 +51,10 @@ class _NfcExchangeSheetState extends State vsync: this, duration: const Duration(milliseconds: 1800), )..repeat(); + _reveal = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + ); _begin(); } @@ -57,6 +63,7 @@ class _NfcExchangeSheetState extends State _sub?.cancel(); _nfc.stop(); _pulse.dispose(); + _reveal.dispose(); super.dispose(); } @@ -107,6 +114,8 @@ class _NfcExchangeSheetState extends State final id = event.id; if (id == null || _peerId != null) return; _peerId = id; + HapticFeedback.mediumImpact(); + _reveal.forward(from: 0); setState(() => _stage = _Stage.found); final info = await ContactInfoFetch.get(id); if (!mounted) return; @@ -181,13 +190,22 @@ class _NfcExchangeSheetState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( + 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( @@ -219,9 +237,11 @@ class _NfcExchangeSheetState extends State child: _buildContent(cs), ), ), - ], + ], + ), ), ), + ), ); } @@ -356,11 +376,28 @@ class _NfcExchangeSheetState extends State padding: const EdgeInsets.symmetric(vertical: 8), child: Column( children: [ - KometAvatar( - name: _peerName(), - imageUrl: _peerInfo?['baseUrl'] as String?, - size: 88, - fontSize: 34, + 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( @@ -435,3 +472,41 @@ class _RadarPainter extends CustomPainter { 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; +} From ad04225a624af12e58c2fd332abe3e4e560edd35 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 21 Jun 2026 12:26:01 +0000 Subject: [PATCH 6/9] =?UTF-8?q?feat(contacts):=20=D0=BE=D0=B1=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=D0=BE=D0=BC=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD=D0=B0=20=D0=BF=D0=BE=20NFC?= =?UTF-8?q?/BLE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/ru/komet/app/BleContactExchange.kt | 18 +++++---- .../main/kotlin/ru/komet/app/MainActivity.kt | 37 ++++++++++++------- .../main/kotlin/ru/komet/app/NfcExchange.kt | 9 +++-- lib/backend/modules/contacts.dart | 8 +++- lib/core/nfc/nfc_exchange_service.dart | 11 ++++-- .../screens/contacts/nfc_exchange_sheet.dart | 14 +++++-- 6 files changed, 65 insertions(+), 32 deletions(-) diff --git a/android/app/src/main/kotlin/ru/komet/app/BleContactExchange.kt b/android/app/src/main/kotlin/ru/komet/app/BleContactExchange.kt index 4a4140f..ac24beb 100644 --- a/android/app/src/main/kotlin/ru/komet/app/BleContactExchange.kt +++ b/android/app/src/main/kotlin/ru/komet/app/BleContactExchange.kt @@ -35,7 +35,7 @@ class BleContactExchange(private val context: Context) { const val MFG_ID = 0x4B4D } - var onReceived: ((Long) -> Unit)? = null + var onReceived: ((Long, Long) -> Unit)? = null var onSent: ((Long) -> Unit)? = null var onError: ((String) -> Unit)? = null @@ -54,11 +54,12 @@ class BleContactExchange(private val context: Context) { @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) { + fun start(selfId: Long, selfSession: String, selfPhone: Long) { val adapter = this.adapter if (adapter == null || !adapter.isEnabled) { emitError("bluetooth_off") @@ -66,6 +67,7 @@ class BleContactExchange(private val context: Context) { } this.selfId = selfId this.selfSession = selfSession + this.selfPhone = selfPhone running = true startGattServer() } @@ -266,8 +268,10 @@ class BleContactExchange(private val context: Context) { } } if (characteristic?.uuid != CHAR_UUID || value == null) return - val peerId = String(value, Charsets.UTF_8).trim().toLongOrNull() ?: return - if (peerId > 0L) emitReceived(peerId) + 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) } } @@ -301,7 +305,7 @@ class BleContactExchange(private val context: Context) { } characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT @Suppress("DEPRECATION") - characteristic.value = selfId.toString().toByteArray(Charsets.UTF_8) + characteristic.value = "$selfId:$selfPhone".toByteArray(Charsets.UTF_8) try { @Suppress("DEPRECATION") gatt.writeCharacteristic(characteristic) @@ -324,8 +328,8 @@ class BleContactExchange(private val context: Context) { } } - private fun emitReceived(id: Long) { - main.post { onReceived?.invoke(id) } + private fun emitReceived(id: Long, phone: Long) { + main.post { onReceived?.invoke(id, phone) } } private fun emitSent(id: Long) { diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 4b39664..18a6d69 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -42,7 +42,9 @@ class MainActivity : FlutterActivity() { 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 { @@ -88,15 +90,12 @@ class MainActivity : FlutterActivity() { when (call.method) { "status" -> result.success(nfcStatus()) "start" -> { - val selfId = when (val v = call.argument("selfId")) { - is Int -> v.toLong() - is Long -> v - else -> 0L - } + val selfId = longArg(call.argument("selfId")) + val selfPhone = longArg(call.argument("selfPhone")) if (selfId <= 0L) { result.error("INVALID_ID", "selfId must be positive", null) } else { - startNfcExchange(selfId) + startNfcExchange(selfId, selfPhone) result.success(null) } } @@ -206,15 +205,18 @@ class MainActivity : FlutterActivity() { ) } - private fun startNfcExchange(selfId: Long) { + 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) @@ -227,7 +229,9 @@ class MainActivity : FlutterActivity() { NfcExchange.active = false NfcExchange.selfId = 0L NfcExchange.selfSession = "" + NfcExchange.selfPhone = 0L NfcExchange.onServed = null + pendingPeer = null nfcHandler.removeCallbacksAndMessages(null) nfcReaderDisable() ble?.stop() @@ -259,12 +263,12 @@ class MainActivity : FlutterActivity() { private fun startBle() { val exchange = ble ?: BleContactExchange(applicationContext).also { - it.onReceived = { id -> onBleReceived(id) } - it.onSent = { id -> onBleReceived(id) } + 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) + exchange.start(pendingSelfId, pendingSession, pendingSelfPhone) } private fun emitExchanging() { @@ -273,9 +277,15 @@ class MainActivity : FlutterActivity() { nfcEvents?.success(mapOf("event" to "exchanging")) } - private fun onBleReceived(id: Long) { + private fun revealPeer(id: Long, phone: Long) { if (id == NfcExchange.selfId || !seenPeers.add(id)) return - nfcEvents?.success(mapOf("event" to "received", "id" to id)) + 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) { @@ -360,8 +370,9 @@ class MainActivity : FlutterActivity() { nfcCycling = false nfcReaderDisable() emitExchanging() + pendingPeer = peer ble?.connectTo(peer.session, peer.id) - nfcHandler.postDelayed({ onBleReceived(peer.id) }, 3000L) + nfcHandler.postDelayed({ revealPeer(peer.id, peer.phone) }, 3000L) } } diff --git a/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt b/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt index 9bf557f..ab5ccaa 100644 --- a/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt +++ b/android/app/src/main/kotlin/ru/komet/app/NfcExchange.kt @@ -11,17 +11,19 @@ object NfcExchange { @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) + 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).toByteArray(Charsets.UTF_8) + STATUS_OK + return (PREFIX + id + ":" + session + ":" + selfPhone) + .toByteArray(Charsets.UTF_8) + STATUS_OK } fun buildSelectCommand(): ByteArray { @@ -42,7 +44,8 @@ object NfcExchange { val id = parts[0].toLongOrNull() ?: return null val session = parts[1] if (session.isEmpty()) return null - return Peer(id, session) + val phone = parts.getOrNull(2)?.toLongOrNull() ?: 0L + return Peer(id, session, phone) } private fun hexToBytes(hex: String): ByteArray { diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 025d169..dac35e8 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -60,8 +60,9 @@ class ContactsModule { static Future addContact( Api api, int id, - String firstName, - ) async { + String firstName, { + int phone = 0, + }) async { final resp = await api.sendRequest(Opcode.contactUpdate, { 'action': 'ADD', 'contactId': id, @@ -92,6 +93,9 @@ class ContactsModule { }; 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++; diff --git a/lib/core/nfc/nfc_exchange_service.dart b/lib/core/nfc/nfc_exchange_service.dart index f7ea9af..898218f 100644 --- a/lib/core/nfc/nfc_exchange_service.dart +++ b/lib/core/nfc/nfc_exchange_service.dart @@ -8,9 +8,10 @@ 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.reason}); + const NfcEvent(this.type, this.id, {this.phone, this.reason}); } class NfcStatus { @@ -47,8 +48,8 @@ class NfcExchangeService { Stream get events => _events.receiveBroadcastStream().map(_decodeEvent); - Future start(int selfId) => - _method.invokeMethod('start', {'selfId': selfId}); + Future start(int selfId, int selfPhone) => + _method.invokeMethod('start', {'selfId': selfId, 'selfPhone': selfPhone}); Future stop() async { if (!_supported) return; @@ -61,9 +62,11 @@ class NfcExchangeService { 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); + return NfcEvent(NfcEventType.received, parsedId, phone: parsedPhone); case 'exchanging': return const NfcEvent(NfcEventType.exchanging, null); case 'error': diff --git a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart index 0520876..ba2127a 100644 --- a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart +++ b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart @@ -9,6 +9,7 @@ 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'; @@ -41,6 +42,7 @@ class _NfcExchangeSheetState extends State _Stage _stage = _Stage.checking; int? _peerId; + int? _peerPhone; Map? _peerInfo; String _failReason = ''; @@ -85,7 +87,7 @@ class _NfcExchangeSheetState extends State return; } _sub = _nfc.events.listen(_onEvent); - await _nfc.start(profile.id); + await _nfc.start(profile.id, profile.phone); if (mounted) setState(() => _stage = _Stage.scanning); } @@ -114,6 +116,7 @@ class _NfcExchangeSheetState extends State 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); @@ -163,7 +166,12 @@ class _NfcExchangeSheetState extends State if (id == null) return; setState(() => _stage = _Stage.adding); try { - await ContactsModule.addContact(api, id, _firstNameForAdd()); + await ContactsModule.addContact( + api, + id, + _firstNameForAdd(), + phone: _peerPhone ?? 0, + ); if (!mounted) return; setState(() => _stage = _Stage.added); showCustomNotification(context, 'Контакт добавлен'); @@ -413,7 +421,7 @@ class _NfcExchangeSheetState extends State ), const SizedBox(height: 4), Text( - 'ID ${_peerId ?? ''}', + formatPhone(_peerPhone) ?? 'ID ${_peerId ?? ''}', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 24), From 23a1bed46abd77258af8b66c82d820d5fe6d93cd Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 21 Jun 2026 12:48:08 +0000 Subject: [PATCH 7/9] =?UTF-8?q?feat(search):=20=D1=8D=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D1=8B,=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D1=8B,=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 82 ++++ lib/backend/modules/contacts.dart | 46 +++ lib/core/storage/app_database.dart | 44 +++ .../screens/chats/chat_list_screen.dart | 61 ++- lib/frontend/screens/chats/search_screen.dart | 366 ++++++++++++++++++ 5 files changed, 567 insertions(+), 32 deletions(-) create mode 100644 lib/frontend/screens/chats/search_screen.dart diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 40ba287..a6d826e 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -161,6 +161,22 @@ 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, + }); +} + sealed class MessageEvent { final int chatId; const MessageEvent(this.chatId); @@ -1151,6 +1167,72 @@ class ChatsModule { return packet.payload; } + static List _parseSearchResult(dynamic payload) { + final result = (payload as Map?)?['result']; + if (result is! List) return const []; + final hits = []; + for (final item in result) { + if (item is! Map) continue; + final chat = item['chat']; + if (chat is! Map) continue; + final id = chat['id']; + if (id is! int) continue; + final last = chat['lastMessage']; + final link = chat['link']; + hits.add(ChatSearchHit( + id: id, + type: (chat['type'] as String?) ?? 'CHAT', + title: chat['title'] as String?, + avatarUrl: chat['baseIconUrl'] as String?, + subtitle: link is String && link.isNotEmpty + ? '@$link' + : (last is Map ? last['text'] as String? : null), + )); + } + return hits; + } + + static Future> searchChats( + 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 _parseSearchResult(packet.payload); + } catch (e) { + logger.w('searchChats failed: $e'); + return const []; + } + } + + static Future> searchPublic( + Api api, + String query, { + int count = 20, + }) async { + final term = query.trim(); + if (term.isEmpty) return const []; + try { + final packet = await api.sendRequest(Opcode.publicSearch, { + 'type': 'ALL', + 'count': count, + 'query': term, + }); + if (packet.isError) return const []; + return _parseSearchResult(packet.payload); + } catch (e) { + logger.w('searchPublic failed: $e'); + return const []; + } + } + static Future createGroupChat( Api api, { required String title, diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index dac35e8..aed44e5 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -54,9 +54,55 @@ class CachedContact { } } +class PhoneLookupResult { + final int id; + final String? name; + final String? avatarUrl; + + const PhoneLookupResult({required this.id, this.name, this.avatarUrl}); +} + class ContactsModule { static final ValueNotifier revision = ValueNotifier(0); + static Future findByPhone(Api api, String phone) async { + final normalized = _normalizePhone(phone); + if (normalized == null) return null; + final packet = await api.sendRequest(Opcode.contactInfoByPhone, { + 'phone': normalized, + }); + if (packet.isError) return null; + final contact = (packet.payload as Map?)?['contact']; + if (contact is! Map) return null; + final id = contact['id']; + if (id is! int) return null; + + String? name; + final names = contact['names']; + if (names is List) { + final n = names.firstWhere((e) => e is Map, orElse: () => null); + if (n is Map) { + final first = + (n['firstName'] as String?) ?? (n['name'] as String?) ?? ''; + final last = (n['lastName'] as String?) ?? ''; + final full = '$first $last'.trim(); + if (full.isNotEmpty) name = full; + } + } + + return PhoneLookupResult( + id: id, + name: name, + avatarUrl: contact['baseUrl'] as String?, + ); + } + + static String? _normalizePhone(String raw) { + final digits = raw.replaceAll(RegExp(r'[^\d]'), ''); + if (digits.length < 5) return null; + return '+$digits'; + } + static Future addContact( Api api, int id, diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 83030dc..37740af 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -578,6 +578,50 @@ class AppDatabase { ); } + static String _escapeLike(String value) => + value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_'); + + static Future>> searchContacts( + int accountId, + String query, { + int limit = 30, + }) async { + final term = query.trim(); + if (term.isEmpty) return const []; + final db = await _instance; + final like = '%${_escapeLike(term)}%'; + return db.query( + 'contacts', + where: 'account_id = ? AND ' + "(first_name LIKE ? ESCAPE '\\' OR last_name LIKE ? ESCAPE '\\' " + "OR CAST(phone AS TEXT) LIKE ? ESCAPE '\\')", + whereArgs: [accountId, like, like, like], + orderBy: 'first_name ASC, last_name ASC', + limit: limit, + ); + } + + static Future>> searchMessages( + int accountId, + String query, { + int limit = 50, + }) async { + final term = query.trim(); + if (term.isEmpty) return const []; + final db = await _instance; + final like = '%${_escapeLike(term)}%'; + return db.rawQuery( + 'SELECT m.id AS id, m.chat_id AS chat_id, m.sender_id AS sender_id, ' + 'm.text AS text, m.time AS time, ' + 'c.title AS chat_title, c.icon_url AS chat_icon, c.type AS chat_type ' + 'FROM messages m ' + 'LEFT JOIN chats_cache c ON c.id = m.chat_id AND c.account_id = m.account_id ' + "WHERE m.account_id = ? AND m.deleted = 0 AND m.text LIKE ? ESCAPE '\\' " + 'ORDER BY m.time DESC LIMIT ?', + [accountId, like, limit], + ); + } + static Future>> loadChatsByIds( int accountId, List ids, diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index c8b0797..19a852d 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -8,6 +8,7 @@ import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'chat_screen.dart'; +import 'search_screen.dart'; import 'create_group_flow.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/online_dot.dart'; @@ -1271,43 +1272,39 @@ class _ChatListScreenState extends State ), Padding( padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), - child: GlossyPill( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(50), - padding: const EdgeInsets.symmetric( - horizontal: 16, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => pushSwipeable( + context, + (_) => const SearchScreen(), ), - depth: 6, - child: SizedBox( - height: 44, - child: Row( - children: [ - Icon( - Symbols.search, - color: cs.outline, - size: 20, - weight: 400, - ), - const SizedBox(width: 10), - Expanded( - child: TextField( + child: GlossyPill( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + depth: 6, + child: SizedBox( + height: 44, + child: Row( + children: [ + Icon( + Symbols.search, + color: cs.outline, + size: 20, + weight: 400, + ), + const SizedBox(width: 10), + Text( + 'Поиск', style: TextStyle( - color: cs.onSurface, + color: cs.outline, fontSize: 15, ), - decoration: InputDecoration( - hintText: 'Поиск', - hintStyle: TextStyle( - color: cs.outline, - fontSize: 15, - ), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/frontend/screens/chats/search_screen.dart b/lib/frontend/screens/chats/search_screen.dart new file mode 100644 index 0000000..82edd00 --- /dev/null +++ b/lib/frontend/screens/chats/search_screen.dart @@ -0,0 +1,366 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../main.dart'; +import '../../../backend/modules/chats.dart'; +import '../../../backend/modules/contacts.dart'; +import '../../../core/storage/app_database.dart'; +import '../../widgets/komet_avatar.dart'; +import '../../widgets/swipe_route.dart'; +import '../contacts/contact_profile_screen.dart'; +import 'chat_screen.dart'; + +class SearchScreen extends StatefulWidget { + const SearchScreen({super.key}); + + @override + State createState() => _SearchScreenState(); +} + +class _SearchScreenState extends State { + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + Timer? _debounce; + int _seq = 0; + int? _accountId; + + bool _loading = false; + PhoneLookupResult? _phoneResult; + List> _contacts = const []; + List _chats = const []; + List> _messages = const []; + List _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 []; + _public = const []; + }); + return; + } + _debounce = Timer(const Duration(milliseconds: 300), _runSearch); + } + + Future _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 >[]) + : AppDatabase.searchContacts(accountId, query), + ChatsModule.searchChats(api, query), + accountId == null + ? Future.value(const >[]) + : AppDatabase.searchMessages(accountId, query), + ChatsModule.searchPublic(api, query), + phoneQuery == null + ? Future.value(null) + : ContactsModule.findByPhone(api, phoneQuery), + ]); + + if (!mounted || token != _seq) return; + + final chats = results[1] as List; + final chatIds = chats.map((c) => c.id).toSet(); + final public = (results[3] as List) + .where((c) => !chatIds.contains(c.id)) + .toList(); + + setState(() { + _phoneResult = results[4] as PhoneLookupResult?; + _contacts = results[0] as List>; + _chats = chats; + _messages = results[2] as List>; + _public = public; + _loading = false; + }); + } + + String _contactName(Map 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 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 hit in _chats) _chatTile(hit), + ], + if (_messages.isNotEmpty) ...[ + _sectionHeader(cs, 'Сообщения'), + for (final row in _messages) + _ResultTile( + name: (row['chat_title'] as String?) ?? '', + imageUrl: row['chat_icon'] as String?, + subtitle: (row['text'] as String?)?.trim(), + onTap: () => _openChat( + row['chat_id'] as int, + (row['chat_title'] as String?) ?? '', + row['chat_icon'] as String?, + (row['chat_type'] as String?) ?? 'CHAT', + ), + ), + ], + 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 _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, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} From 721515729c602159255cfaf6126b3eaaae28da41 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 21 Jun 2026 13:37:31 +0000 Subject: [PATCH 8/9] =?UTF-8?q?fix(search):=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=20=D0=BF=D0=BE=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=D0=BC=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20cha?= =?UTF-8?q?tSearch=20+=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B2=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 76 ++++++++++++++++++- lib/core/storage/app_database.dart | 19 ++--- lib/frontend/screens/chats/chat_screen.dart | 5 ++ lib/frontend/screens/chats/search_screen.dart | 69 ++++++++++++----- 4 files changed, 134 insertions(+), 35 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index a6d826e..14f1af8 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -177,6 +177,22 @@ class ChatSearchHit { }); } +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); @@ -1192,7 +1208,28 @@ class ChatsModule { return hits; } - static Future> searchChats( + static List _parseMessageResult(dynamic payload) { + final result = (payload as Map?)?['result']; + if (result is! List) return const []; + final hits = []; + for (final item in result) { + if (item is! Map) continue; + final message = item['message']; + if (message is! Map) continue; + final chatId = item['chatId']; + if (chatId is! int || chatId == 0) continue; + hits.add(MessageSearchHit( + chatId: chatId, + messageId: message['id']?.toString(), + text: message['text'] as String?, + time: (message['time'] as int?) ?? 0, + senderId: (message['sender'] as int?) ?? 0, + )); + } + return hits; + } + + static Future> searchMessages( Api api, String query, { int count = 50, @@ -1205,9 +1242,9 @@ class ChatsModule { 'query': term, }); if (packet.isError) return const []; - return _parseSearchResult(packet.payload); + return _parseMessageResult(packet.payload); } catch (e) { - logger.w('searchChats failed: $e'); + logger.w('searchMessages failed: $e'); return const []; } } @@ -1233,6 +1270,39 @@ class ChatsModule { } } + static Future subscribeChat( + Api api, + int chatId, { + bool subscribe = true, + }) async { + try { + await api.sendRequest(Opcode.chatSubscribe, { + 'chatId': chatId, + 'subscribe': subscribe, + }); + } catch (e) { + logger.w('subscribeChat failed: $e'); + } + } + + static Future ensureChatCached( + Api api, + int accountId, + int chatId, + ) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isNotEmpty) return true; + try { + final info = await getChatInfo(api, chatId); + if (info == null) return false; + await cacheServerChat(info, accountId); + return true; + } catch (e) { + logger.w('ensureChatCached failed for $chatId: $e'); + return false; + } + } + static Future createGroupChat( Api api, { required String title, diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 37740af..68ddc71 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -601,24 +601,21 @@ class AppDatabase { ); } - static Future>> searchMessages( + static Future>> searchChatsByTitle( int accountId, String query, { - int limit = 50, + int limit = 30, }) async { final term = query.trim(); if (term.isEmpty) return const []; final db = await _instance; final like = '%${_escapeLike(term)}%'; - return db.rawQuery( - 'SELECT m.id AS id, m.chat_id AS chat_id, m.sender_id AS sender_id, ' - 'm.text AS text, m.time AS time, ' - 'c.title AS chat_title, c.icon_url AS chat_icon, c.type AS chat_type ' - 'FROM messages m ' - 'LEFT JOIN chats_cache c ON c.id = m.chat_id AND c.account_id = m.account_id ' - "WHERE m.account_id = ? AND m.deleted = 0 AND m.text LIKE ? ESCAPE '\\' " - 'ORDER BY m.time DESC LIMIT ?', - [accountId, like, limit], + return db.query( + 'chats_cache', + where: "account_id = ? AND title LIKE ? ESCAPE '\\'", + whereArgs: [accountId, like], + orderBy: 'last_event_time DESC', + limit: limit, ); } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 26b7353..0da0204 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -409,6 +409,11 @@ class _ChatScreenState extends State } try { + final cachedRows = await AppDatabase.loadChat(_myId, widget.chatId); + if (cachedRows.isEmpty) { + await ChatsModule.ensureChatCached(api, _myId, widget.chatId); + await ChatsModule.subscribeChat(api, widget.chatId); + } final serverMessages = await messagesModule.fetchHistory( _myId, widget.chatId, diff --git a/lib/frontend/screens/chats/search_screen.dart b/lib/frontend/screens/chats/search_screen.dart index 82edd00..e042a18 100644 --- a/lib/frontend/screens/chats/search_screen.dart +++ b/lib/frontend/screens/chats/search_screen.dart @@ -29,8 +29,9 @@ class _SearchScreenState extends State { bool _loading = false; PhoneLookupResult? _phoneResult; List> _contacts = const []; - List _chats = const []; - List> _messages = const []; + List> _chats = const []; + List _messages = const []; + Map> _msgChatMeta = const {}; List _public = const []; @override @@ -62,10 +63,14 @@ class _SearchScreenState extends State { _contacts = const []; _chats = const []; _messages = const []; + _msgChatMeta = const {}; _public = const []; }); return; } + if (_phoneResult != null) { + setState(() => _phoneResult = null); + } _debounce = Timer(const Duration(milliseconds: 300), _runSearch); } @@ -81,10 +86,10 @@ class _SearchScreenState extends State { accountId == null ? Future.value(const >[]) : AppDatabase.searchContacts(accountId, query), - ChatsModule.searchChats(api, query), accountId == null ? Future.value(const >[]) - : AppDatabase.searchMessages(accountId, query), + : AppDatabase.searchChatsByTitle(accountId, query), + ChatsModule.searchMessages(api, query), ChatsModule.searchPublic(api, query), phoneQuery == null ? Future.value(null) @@ -93,17 +98,27 @@ class _SearchScreenState extends State { if (!mounted || token != _seq) return; - final chats = results[1] as List; - final chatIds = chats.map((c) => c.id).toSet(); + final chats = results[1] as List>; + final messages = results[2] as List; + final localChatIds = chats.map((c) => c['id'] as int).toSet(); final public = (results[3] as List) - .where((c) => !chatIds.contains(c.id)) + .where((c) => !localChatIds.contains(c.id)) .toList(); + var meta = >{}; + 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>; _chats = chats; - _messages = results[2] as List>; + _messages = messages; + _msgChatMeta = meta; _public = public; _loading = false; }); @@ -246,22 +261,21 @@ class _SearchScreenState extends State { ], if (_chats.isNotEmpty) ...[ _sectionHeader(cs, 'Чаты'), - for (final hit in _chats) _chatTile(hit), + 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 row in _messages) - _ResultTile( - name: (row['chat_title'] as String?) ?? '', - imageUrl: row['chat_icon'] as String?, - subtitle: (row['text'] as String?)?.trim(), - onTap: () => _openChat( - row['chat_id'] as int, - (row['chat_title'] as String?) ?? '', - row['chat_icon'] as String?, - (row['chat_type'] as String?) ?? 'CHAT', - ), - ), + for (final hit in _messages) _messageTile(hit), ], if (_public.isNotEmpty) ...[ _sectionHeader(cs, 'Глобальный поиск'), @@ -279,6 +293,19 @@ class _SearchScreenState extends State { 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( From 1b77941196a8d4d4a1bd441f9d8500f85387594f Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 21 Jun 2026 14:31:04 +0000 Subject: [PATCH 9/9] =?UTF-8?q?fix(chats):=20=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=BC=D0=BE=D1=82=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=D0=BB=20=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=BF?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D0=B5=D1=82=20=D0=B2=20=D1=81=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=BE=D0=BA=20=D1=87=D0=B0=D1=82=D0=BE=D0=B2=20(=D1=84?= =?UTF-8?q?=D0=BB=D0=B0=D0=B3=20in=5Flist)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 9 ++++++--- lib/core/storage/app_database.dart | 14 ++++++++++---- lib/frontend/screens/chats/chat_screen.dart | 5 +++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 14f1af8..08f9f30 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -846,6 +846,7 @@ class ChatsModule { Map chat, int accountId, { Map? preloadedExisting, + bool inList = true, }) async { final cachedAt = DateTime.now().millisecondsSinceEpoch; final id = chat['id']; @@ -876,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; } @@ -953,7 +956,7 @@ class ChatsModule { ), ) .whereType() - .map((c) => c.toDbRow()) + .map((c) => c.toDbRow()..['in_list'] = 1) .toList(); if (rows.isNotEmpty) { @@ -1295,7 +1298,7 @@ class ChatsModule { try { final info = await getChatInfo(api, chatId); if (info == null) return false; - await cacheServerChat(info, accountId); + await cacheServerChat(info, accountId, inList: false); return true; } catch (e) { logger.w('ensureChatCached failed for $chatId: $e'); diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 68ddc71..f604cbf 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -185,7 +185,7 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 13, + version: 14, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -248,6 +248,11 @@ class AppDatabase { 'ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0', ); } + if (oldVersion < 14) { + await db.execute( + 'ALTER TABLE chats_cache ADD COLUMN in_list INTEGER NOT NULL DEFAULT 1', + ); + } }, ); } @@ -335,6 +340,7 @@ class AppDatabase { options TEXT, owner INTEGER, admins TEXT, + in_list INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (id, account_id) ) '''; @@ -534,7 +540,7 @@ class AppDatabase { final db = await _instance; return db.query( 'chats_cache', - where: 'account_id = ?', + where: 'account_id = ? AND in_list = 1', whereArgs: [accountId], orderBy: 'last_event_time DESC', ); @@ -543,8 +549,8 @@ class AppDatabase { static Future sumUnread(int accountId, {int? excludeChatId}) async { final db = await _instance; final where = excludeChatId != null - ? 'account_id = ? AND id != ?' - : 'account_id = ?'; + ? 'account_id = ? AND in_list = 1 AND id != ?' + : 'account_id = ? AND in_list = 1'; final args = excludeChatId != null ? [accountId, excludeChatId] : [accountId]; diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 0da0204..3364e7d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -164,6 +164,7 @@ class _ChatScreenState extends State late AnimationController _shimmerController; Timer? _shimmerStartTimer; bool _historyKickedOff = false; + bool _previewChat = false; List _messages = []; final ValueNotifier _messagesRev = ValueNotifier(0); final Set _deletingIds = {}; @@ -411,6 +412,7 @@ class _ChatScreenState extends State try { final cachedRows = await AppDatabase.loadChat(_myId, widget.chatId); if (cachedRows.isEmpty) { + _previewChat = true; await ChatsModule.ensureChatCached(api, _myId, widget.chatId); await ChatsModule.subscribeChat(api, widget.chatId); } @@ -537,6 +539,9 @@ class _ChatScreenState extends State @override void dispose() { + if (_previewChat) { + unawaited(ChatsModule.subscribeChat(api, widget.chatId, subscribe: false)); + } WidgetsBinding.instance.removeObserver(this); ChatsModule.chatsChanged.removeListener(_onChatsBump); _otherUnread.dispose();