diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 470b795..df07758 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -102,6 +102,14 @@ class BufferWriter { } writeBytes(bytes); } + + void writeHex(String hex) { + List result = []; + for (int i = 0; i < hex.length ~/ 2; i++) { + result.add(int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16)); + } + writeBytes(Uint8List.fromList(result)); + } } // Command codes (to device) @@ -164,6 +172,7 @@ const int respCodeContactMsgRecv = 7; const int respCodeChannelMsgRecv = 8; const int respCodeCurrTime = 9; const int respCodeNoMoreMessages = 10; +const int respCodeExportContact = 11; const int respCodeBattAndStorage = 12; const int respCodeDeviceInfo = 13; const int respCodeContactMsgRecvV3 = 16; @@ -728,4 +737,22 @@ Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) writer.writeBytes(payload); } return writer.toBytes(); +} + +// Build a export contact frame +// [cmd][pub_key x32 / if empty exports your contact info] +Uint8List buildExportContactFrame(Uint8List pubKey) { + final writer = BufferWriter(); + writer.writeByte(cmdExportContact); + writer.writeBytes(pubKey); + return writer.toBytes(); +} + +// Build a import contact frame +// [cmd][contact_frame x98+] +Uint8List buildImportContactFrame(String contactFrame) { + final writer = BufferWriter(); + writer.writeByte(cmdImportContact); + writer.writeHex(contactFrame); + return writer.toBytes(); } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cb7b95e..1203b20 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1316,6 +1316,7 @@ "pathTrace_failed": "Path trace failed.", "pathTrace_notAvailable": "Path trace not available.", "pathTrace_refreshTooltip": "Refresh Path Trace.", + "contacts_pathTrace": "Path Trace", "contacts_ping": "Ping", "contacts_repeaterPathTrace": "Path trace to repeater", @@ -1328,6 +1329,14 @@ "placeholders": { "name": {"type": "String"} } - } + }, + "contacts_clipboardEmpty": "Clipboard Is Empty.", + "contacts_invalidAdvertFormat": "Invalid Contact Data", + "contacts_contactImported": "Contact has been Imported.", + "contacts_contactImportFailed": "Contact Failed to Imported.", + "contacts_zeroHopAdvert":"Zero Hop Advert", + "contacts_floodAdvert":"Flood Advert", + "contacts_copyAdvertToClipboard":"Copy Advert to Clipboard", + "contacts_addContactFromClipboard":"Add Contact from Clipboard" } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index d12abb0..59a8d05 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:meshcore_open/widgets/path_trace_dialog.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; @@ -53,17 +53,22 @@ class _ContactsScreenState extends State final ContactGroupStore _groupStore = ContactGroupStore(); List _groups = []; Timer? _searchDebounce; - + + bool _imported = false; + StreamSubscription? _frameSubscription; + @override void initState() { super.initState(); _loadGroups(); + _setupFrameListener(); } @override void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); + _frameSubscription?.cancel(); super.dispose(); } @@ -79,6 +84,74 @@ class _ContactsScreenState extends State await _groupStore.saveGroups(_groups); } + void _setupFrameListener() { + final connector = Provider.of(context, listen: false); + // Listen for incoming text messages from the repeater + _frameSubscription = connector.receivedFrames.listen((frame) { + if (frame.isEmpty) return; + final frameBuffer = BufferReader(frame); + final code = frameBuffer.readUInt8(); + + if (code == respCodeExportContact) { + final advertPacket = frameBuffer.readRemainingBytes(); + final hexString = pubKeyToHex(advertPacket); + Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); + } + + if(code == respCodeOk && _imported) { + // Show a snackbar indicating success + _imported = false; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.contacts_contactImported)), + ); + } + + if(code == respCodeErr && _imported) { + // Show a snackbar indicating success + _imported = false; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.contacts_contactImportFailed)), + ); + } + + }); + } + + Future _contactExport(Uint8List pubKey) async { + final connector = Provider.of(context, listen: false); + final exportContactFrame = buildExportContactFrame(pubKey); + await connector.sendFrame(exportContactFrame); + return; + } + + Future _contactImport() async { + final clipboardData = await Clipboard.getData('text/plain'); + if (clipboardData == null || clipboardData.text == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.contacts_clipboardEmpty)), + ); + return; + } + final text = clipboardData.text!.trim(); + if (!text.startsWith('meshcore://')) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)), + ); + return; + } + final hexString = text.substring('meshcore://'.length); + try { + final connector = Provider.of(context, listen: false); + final importContactFrame = buildImportContactFrame(hexString); + await connector.sendFrame(importContactFrame); + _imported = true; + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)), + ); + } + } + @override Widget build(BuildContext context) { final connector = context.watch(); @@ -98,18 +171,87 @@ class _ContactsScreenState extends State centerTitle: true, automaticallyImplyLeading: false, actions: [ - IconButton( - icon: const Icon(Icons.bluetooth_disabled), - tooltip: context.l10n.common_disconnect, - onPressed: () => _disconnect(context, connector), - ), - IconButton( - icon: const Icon(Icons.tune), - tooltip: context.l10n.common_settings, - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (context) => const SettingsScreen()), + PopupMenuButton(itemBuilder: (context) => [ + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.connect_without_contact), + const SizedBox(width: 8), + Text(context.l10n.contacts_zeroHopAdvert), + ], + ), + onTap: () => { + connector.sendSelfAdvert(flood: false), + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.settings_advertisementSent))), + }, ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.cell_tower), + const SizedBox(width: 8), + Text(context.l10n.contacts_floodAdvert), + ], + ), + onTap: () => { + connector.sendSelfAdvert(flood: true), + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.settings_advertisementSent))), + }, + ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.copy), + const SizedBox(width: 8), + Text(context.l10n.contacts_copyAdvertToClipboard), + ], + ), + onTap: () => _contactExport(Uint8List.fromList([])), + ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.paste), + const SizedBox(width: 8), + Text(context.l10n.contacts_addContactFromClipboard), + ], + ), + onTap: () => _contactImport(), + ), + ], + icon: const Icon(Icons.connect_without_contact), + ), + PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.logout, color: Colors.red), + const SizedBox(width: 8), + Text(context.l10n.common_disconnect), + ], + ), + onTap: () => _disconnect(context, connector), + ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.settings), + const SizedBox(width: 8), + Text(context.l10n.settings_title), + ], + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ), + ), + ], + icon: const Icon(Icons.more_vert), ), ], ), @@ -834,6 +976,7 @@ class _ContactsScreenState extends State _openChat(context, contact); }, ), + ], ListTile( leading: const Icon(Icons.delete, color: Colors.red), title: Text( @@ -906,9 +1049,10 @@ class _ContactTile extends StatelessWidget { child: _buildContactAvatar(contact), ), title: Text(contact.name), - subtitle: Text( - '${contact.typeLabel} • ${contact.pathLabel} ${contact.shortPubKeyHex}', - ), + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(contact.pathLabel), + Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)) + ],), // Clamp text scaling in trailing section to prevent overflow while // maintaining accessibility. Primary content (title/subtitle) scales normally. trailing: MediaQuery( @@ -929,8 +1073,13 @@ class _ContactTile extends StatelessWidget { _formatLastSeen(context, lastSeen), style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), - if (contact.hasLocation) - Icon(Icons.location_on, size: 14, color: Colors.grey[400]), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (contact.hasLocation) + Icon(Icons.location_on, size: 14, color: Colors.grey[400]), + ], + ), ], ), ),