feat(contacts): добавление контакта через NFC

This commit is contained in:
klockky
2026-06-21 07:49:15 +00:00
parent d53f974ddb
commit 1299f1af2f
10 changed files with 722 additions and 1 deletions
+13
View File
@@ -15,6 +15,8 @@
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.NFC"/>
<uses-feature android:name="android.hardware.nfc.hce" android:required="false"/>
<application
android:label="Komet"
android:name="${applicationName}"
@@ -67,6 +69,17 @@
android:name=".UploadForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<service
android:name=".NfcHostApduService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/komet_nfc_apdu"/>
</service>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -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<Long>()
@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<Any>("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<String, Any> {
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
@@ -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
}
}
@@ -0,0 +1,25 @@
package ru.komet.app
import android.nfc.cardemulation.HostApduService
import android.os.Bundle
class NfcHostApduService : HostApduService() {
private val selectHeader = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00)
private val statusNotFound = byteArrayOf(0x6A, 0x82.toByte())
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
if (commandApdu == null || !isSelectApdu(commandApdu)) return statusNotFound
return NfcExchange.buildSelectResponse()
}
override fun onDeactivated(reason: Int) {}
private fun isSelectApdu(apdu: ByteArray): Boolean {
if (apdu.size < selectHeader.size) return false
for (i in selectHeader.indices) {
if (apdu[i] != selectHeader[i]) return false
}
return true
}
}
@@ -0,0 +1,4 @@
<resources>
<string name="nfc_service_description">Komet contact exchange</string>
<string name="nfc_aid_group_description">Komet contact exchange</string>
</resources>
@@ -0,0 +1,9 @@
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/nfc_service_description"
android:requireDeviceUnlock="false">
<aid-group
android:description="@string/nfc_aid_group_description"
android:category="other">
<aid-filter android:name="F04B4F4D455431"/>
</aid-group>
</host-apdu-service>
+47
View File
@@ -1,4 +1,8 @@
import 'package:flutter/foundation.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/storage/app_database.dart';
import '../api.dart';
import 'messages.dart';
class CachedContact {
@@ -51,6 +55,49 @@ class CachedContact {
}
class ContactsModule {
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
static Future<CachedContact?> 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<dynamic, dynamic>()
: null;
final row = contact != null
? _parseContact(contact, profile.id)
: {
'id': id,
'account_id': profile.id,
'first_name': firstName,
'last_name': null,
'phone': 0,
'photo_id': null,
'base_url': null,
'base_raw_url': null,
'update_time': 0,
'options': null,
};
if (row == null) return null;
await AppDatabase.saveContacts([row]);
if (contact != null) _primeContactCache(contact);
revision.value++;
return CachedContact.fromDbRow(row);
}
static Future<void> syncFromLoginPayload(
Map<dynamic, dynamic> data,
int accountId,
+67
View File
@@ -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<NfcStatus> status() async {
if (!_supported) return const NfcStatus(supported: false, enabled: false);
try {
final res = await _method.invokeMapMethod<String, dynamic>('status');
return NfcStatus(
supported: res?['supported'] == true,
enabled: res?['enabled'] == true,
);
} catch (_) {
return const NfcStatus(supported: false, enabled: false);
}
}
Stream<NfcEvent> get events =>
_events.receiveBroadcastStream().map(_decodeEvent);
Future<void> start(int selfId) =>
_method.invokeMethod('start', {'selfId': selfId});
Future<void> stop() async {
if (!_supported) return;
try {
await _method.invokeMethod('stop');
} catch (_) {}
}
NfcEvent _decodeEvent(dynamic raw) {
final map = raw is Map ? raw : const {};
final id = map['id'];
final type = map['event'] == 'received'
? NfcEventType.received
: NfcEventType.cancelled;
return NfcEvent(type, id is int ? id : (id is num ? id.toInt() : null));
}
}
@@ -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<ContactsTab> {
void initState() {
super.initState();
_loadContacts();
ContactsModule.revision.addListener(_loadContacts);
}
@override
void dispose() {
ContactsModule.revision.removeListener(_loadContacts);
super.dispose();
}
Future<void> _openNfcExchange() async {
final cs = Theme.of(context).colorScheme;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh,
shape: kSheetShape,
builder: (_) => const NfcExchangeSheet(),
);
}
Future<void> _openSearchById() async {
@@ -188,7 +207,7 @@ class _ContactsTabState extends State<ContactsTab> {
),
IconButton(
icon: Icon(Symbols.person_add, color: cs.onSurface),
onPressed: () {},
onPressed: _openNfcExchange,
),
IconButton(
icon: Icon(Symbols.search, color: cs.onSurface),
@@ -0,0 +1,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<NfcExchangeSheet> createState() => _NfcExchangeSheetState();
}
class _NfcExchangeSheetState extends State<NfcExchangeSheet>
with SingleTickerProviderStateMixin {
final _nfc = NfcExchangeService.instance;
late final AnimationController _pulse;
StreamSubscription<NfcEvent>? _sub;
_Stage _stage = _Stage.checking;
int? _peerId;
Map<String, dynamic>? _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<void> _begin() async {
final status = await _nfc.status();
if (!mounted) return;
if (!status.supported) {
setState(() => _stage = _Stage.unsupported);
return;
}
if (!status.enabled) {
setState(() => _stage = _Stage.disabled);
return;
}
final profile = await AppDatabase.loadActiveProfile();
if (!mounted) return;
if (profile == null) {
setState(() => _stage = _Stage.unsupported);
return;
}
_sub = _nfc.events.listen(_onEvent);
await _nfc.start(profile.id);
if (mounted) setState(() => _stage = _Stage.scanning);
}
Future<void> _onEvent(NfcEvent event) async {
if (event.type == NfcEventType.cancelled) {
if (mounted && _stage == _Stage.scanning) {
setState(() => _stage = _Stage.disabled);
}
return;
}
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<void> _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;
}