diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 166c98f..b22a358 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1996,12 +1996,21 @@ class MeshCoreConnector extends ChangeNotifier { final settings = _appSettingsService!.settings; if (settings.notificationsEnabled && settings.notifyOnNewMessage) { // Find the contact name - _notificationService.showMessageNotification( - contactName: contact?.name ?? 'Unknown', - message: message.text, - contactId: message.senderKeyHex, - badgeCount: getTotalUnreadCount(), - ); + if (contact?.type == advTypeChat) { + _notificationService.showMessageNotification( + contactName: contact?.name ?? 'Unknown', + message: message.text, + contactId: message.senderKeyHex, + badgeCount: getTotalUnreadCount(), + ); + } else if (contact?.type == advTypeRoom) { + _notificationService.showMessageNotification( + contactName: contact?.name ?? 'Unknown Room', + message: message.text.substring(4), + contactId: message.senderKeyHex, + badgeCount: getTotalUnreadCount(), + ); + } } } _handleQueuedMessageReceived(); @@ -2027,7 +2036,7 @@ class MeshCoreConnector extends ChangeNotifier { final baseTextOffset = timestampOffset + 4; if (frame.length <= baseTextOffset) return null; - + final fourBytePubMSG = frame.sublist(baseTextOffset, baseTextOffset + 4); final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen); final flags = frame[txtTypeOffset]; final shiftedType = flags >> 2; @@ -2065,6 +2074,7 @@ class MeshCoreConnector extends ChangeNotifier { status: MessageStatus.delivered, pathLength: pathLenByte == 0xFF ? 0 : pathLenByte, pathBytes: Uint8List(0), + fourByteRoomContactKey: fourBytePubMSG ); } diff --git a/lib/models/message.dart b/lib/models/message.dart index cbcd111..bd397d7 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -23,6 +23,7 @@ class Message { final int? pathLength; final Uint8List pathBytes; final Map reactions; + final Uint8List fourByteRoomContactKey; Message({ required this.senderKey, @@ -40,8 +41,10 @@ class Message { this.tripTimeMs, this.pathLength, Uint8List? pathBytes, + Uint8List? fourByteRoomContactKey, Map? reactions, }) : pathBytes = pathBytes ?? Uint8List(0), + fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0), reactions = reactions ?? {}; String get senderKeyHex => pubKeyToHex(senderKey); @@ -58,6 +61,7 @@ class Message { Uint8List? pathBytes, bool? isCli, Map? reactions, + Uint8List? fourByteRoomContactKey, }) { return Message( senderKey: senderKey, @@ -76,6 +80,7 @@ class Message { pathLength: pathLength ?? this.pathLength, pathBytes: pathBytes ?? this.pathBytes, reactions: reactions ?? this.reactions, + fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey, ); } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 62da512..ce4c3e1 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; @@ -162,13 +163,12 @@ class _ChatScreenState extends State { body: Consumer( builder: (context, connector, child) { final messages = connector.getMessages(widget.contact); - return Column( children: [ Expanded( child: messages.isEmpty ? _buildEmptyState() - : _buildMessageList(messages), + : _buildMessageList(messages, connector), ), _buildInputBar(connector), ], @@ -199,18 +199,29 @@ class _ChatScreenState extends State { ); } - Widget _buildMessageList(List messages) { + Widget _buildMessageList(List messages, MeshCoreConnector connector) { return ListView.builder( controller: _scrollController, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), itemCount: messages.length, itemBuilder: (context, index) { + Contact contact = widget.contact; final message = messages[index]; + String fourByteHex = ''; + if (widget.contact.type == advTypeRoom) { + contact = _resolveContactFrom4Bytes( + connector, + message.fourByteRoomContactKey.isEmpty ? Uint8List.fromList([0, 0, 0, 0]) : message.fourByteRoomContactKey, + ); + fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); + } + return _MessageBubble( message: message, - senderName: widget.contact.name, - onTap: () => _openMessagePath(message), - onLongPress: () => _showMessageActions(message), + senderName: widget.contact.type == advTypeRoom ? "${contact.name} [$fourByteHex]" : contact.name, + isRoomServer: widget.contact.type == advTypeRoom, + onTap: () => _openMessagePath(message, contact), + onLongPress: () => _showMessageActions(message, contact), ); }, ); @@ -579,6 +590,13 @@ class _ChatScreenState extends State { ); } + Contact _resolveContactFrom4Bytes(MeshCoreConnector connector, Uint8List key4Bytes) { + return connector.contacts.firstWhere( + (c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)), + orElse: () => widget.contact, + ); + } + String _currentPathLabel(Contact contact) { // Check if user has set a path override if (contact.pathOverride != null) { @@ -684,6 +702,15 @@ class _ChatScreenState extends State { ); } + void _openChat(BuildContext context, Contact contact) { + // Check if this is a repeater + context.read().markContactRead(contact.publicKeyHex); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)), + ); + } + Future _showCustomPathDialog(BuildContext context) async { final connector = Provider.of(context, listen: false); @@ -734,10 +761,20 @@ class _ChatScreenState extends State { } - void _openMessagePath(Message message) { + void _openMessagePath(Message message, Contact contact) { final connector = context.read(); - final senderName = - message.isOutgoing ? (connector.selfName ?? 'Me') : widget.contact.name; + final fourByteHex = message.fourByteRoomContactKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); + final String senderName; + if (message.isOutgoing) { + senderName = connector.selfName ?? 'Me'; + } else if (widget.contact.type == advTypeRoom) { + senderName = "${contact.name} [$fourByteHex]"; + } else { + senderName = widget.contact.name; + } final pathMessage = ChannelMessage( senderKey: null, senderName: senderName, @@ -757,7 +794,7 @@ class _ChatScreenState extends State { ); } - void _showMessageActions(Message message) { + void _showMessageActions(Message message, Contact contact) { showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( @@ -798,6 +835,15 @@ class _ChatScreenState extends State { _retryMessage(message); }, ), + if (widget.contact.type == advTypeRoom) + ListTile( + leading: const Icon(Icons.chat), + title: const Text('Open Chat'), + onTap: () { + Navigator.pop(sheetContext); + _openChat(context, contact); + }, + ), ListTile( leading: const Icon(Icons.close), title: const Text('Cancel'), @@ -862,12 +908,14 @@ class _ChatScreenState extends State { class _MessageBubble extends StatelessWidget { final Message message; final String senderName; + final bool isRoomServer; final VoidCallback? onTap; final VoidCallback? onLongPress; const _MessageBubble({ required this.message, required this.senderName, + required this.isRoomServer, this.onTap, this.onLongPress, }); @@ -886,7 +934,10 @@ class _MessageBubble extends StatelessWidget { ? colorScheme.onErrorContainer : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); final metaColor = textColor.withValues(alpha: 0.7); - + String messageText = message.text; + if (isRoomServer && !isOutgoing) { + messageText = message.text.substring(4.clamp(0, message.text.length)); + } return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( @@ -937,7 +988,7 @@ class _MessageBubble extends StatelessWidget { ) else Text( - message.text, + messageText, style: TextStyle( color: textColor, ), diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index e16da6b..32799eb 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -18,6 +18,7 @@ import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/repeater_login_dialog.dart'; +import '../widgets/room_login_dialog.dart'; import '../widgets/unread_badge.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; @@ -384,6 +385,8 @@ class _ContactsScreenState extends State // Check if this is a repeater if (contact.type == advTypeRepeater) { _showRepeaterLogin(context, contact); + } else if (contact.type == advTypeRoom) { + _showRoomLogin(context, contact); } else { context.read().markContactRead(contact.publicKeyHex); Navigator.push( @@ -436,6 +439,25 @@ class _ContactsScreenState extends State ); } + void _showRoomLogin(BuildContext context, Contact room) { + showDialog( + context: context, + builder: (context) => RoomLoginDialog( + room: room, + onLogin: (password) { + // Navigate to chat screen after successful login + context.read().markContactRead(room.publicKeyHex); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen(contact: room), + ), + ); + }, + ), + ); + } + void _showGroupOptions(BuildContext context, ContactGroup group, List contacts) { final members = _resolveGroupContacts(group, contacts); showModalBottomSheet( @@ -642,6 +664,7 @@ class _ContactsScreenState extends State Contact contact, ) { final isRepeater = contact.type == advTypeRepeater; + final isRoom = contact.type == advTypeRoom; showModalBottomSheet( context: context, @@ -658,6 +681,15 @@ class _ContactsScreenState extends State _showRepeaterLogin(context, contact); }, ) + else if (isRoom) + ListTile( + leading: const Icon(Icons.room, color: Colors.blue), + title: const Text('Room Login'), + onTap: () { + Navigator.pop(context); + _showRoomLogin(context, contact); + }, + ) else ListTile( leading: const Icon(Icons.chat), diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 93c8988..4dc7971 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -18,6 +18,7 @@ import 'channels_screen.dart'; import 'chat_screen.dart'; import 'contacts_screen.dart'; import '../widgets/repeater_login_dialog.dart'; +import '../widgets/room_login_dialog.dart'; import 'repeater_hub_screen.dart'; import 'settings_screen.dart'; @@ -572,6 +573,25 @@ class _MapScreenState extends State { ); } + void _showRoomLogin(BuildContext context, Contact room) { + showDialog( + context: context, + builder: (context) => RoomLoginDialog( + room: room, + onLogin: (password) { + // Navigate to chat screen after successful login + context.read().markContactRead(room.publicKeyHex); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen(contact: room), + ), + ); + }, + ), + ); + } + void _showNodeInfo(BuildContext context, Contact contact) { showDialog( context: context, @@ -624,6 +644,14 @@ class _MapScreenState extends State { }, child: const Text('Manage Repeater'), ), + if (contact.type == advTypeRoom) + TextButton( + onPressed: () { + Navigator.pop(context); + _showRoomLogin(context, contact); + }, + child: const Text('Join Room'), + ), ], ), ); diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 2870d25..c1bc640 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -52,6 +52,7 @@ class MessageStore { 'pathLength': msg.pathLength, 'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null, 'reactions': msg.reactions, + 'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey), }; } @@ -86,6 +87,9 @@ class MessageStore { reactions: (json['reactions'] as Map?)?.map( (key, value) => MapEntry(key, value as int), ) ?? {}, + fourByteRoomContactKey: json['fourByteRoomContactKey'] != null + ? Uint8List.fromList(base64Decode(json['fourByteRoomContactKey'] as String)) + : null, ); } } diff --git a/lib/widgets/room_login_dialog.dart b/lib/widgets/room_login_dialog.dart new file mode 100644 index 0000000..dc4592d --- /dev/null +++ b/lib/widgets/room_login_dialog.dart @@ -0,0 +1,416 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:provider/provider.dart'; +import '../models/contact.dart'; +import '../services/storage_service.dart'; +import '../connector/meshcore_connector.dart'; +import '../connector/meshcore_protocol.dart'; +import '../utils/app_logger.dart'; +import 'path_management_dialog.dart'; + +class RoomLoginDialog extends StatefulWidget { + final Contact room; + final Function(String password) onLogin; + + const RoomLoginDialog({ + super.key, + required this.room, + required this.onLogin, + }); + + @override + State createState() => _RoomLoginDialogState(); +} + +class _RoomLoginDialogState extends State { + final TextEditingController _passwordController = TextEditingController(); + final StorageService _storage = StorageService(); + bool _savePassword = false; + bool _isLoading = true; + bool _obscurePassword = true; + late MeshCoreConnector _connector; + int _currentAttempt = 0; + static const int _maxAttempts = 5; + + @override + void initState() { + super.initState(); + _connector = Provider.of(context, listen: false); + _loadSavedPassword(); + } + + Future _loadSavedPassword() async { + final savedPassword = + await _storage.getRepeaterPassword(widget.room.publicKeyHex); + if (savedPassword != null) { + setState(() { + _passwordController.text = savedPassword; + _savePassword = true; + _isLoading = false; + }); + } else { + setState(() { + _isLoading = false; + }); + } + } + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + bool _isLoggingIn = false; + + Contact _resolveRepeater(MeshCoreConnector connector) { + return connector.contacts.firstWhere( + (c) => c.publicKeyHex == widget.room.publicKeyHex, + orElse: () => widget.room, + ); + } + + Future _handleLogin() async { + if (_isLoggingIn) return; + + setState(() { + _isLoggingIn = true; + _currentAttempt = 0; + }); + + try { + final password = _passwordController.text; + final room = _resolveRepeater(_connector); + appLogger.info( + 'Login started for ${room.name} (${room.publicKeyHex})', + tag: 'RoomLogin', + ); + final selection = await _connector.preparePathForContactSend(room); + final loginFrame = buildSendLoginFrame(room.publicKey, password); + final pathLengthValue = selection.useFlood ? -1 : selection.hopCount; + final timeoutMs = _connector.calculateTimeout( + pathLength: pathLengthValue, + messageBytes: loginFrame.length, + ); + final timeoutSeconds = (timeoutMs / 1000).ceil(); + final timeout = Duration(milliseconds: timeoutMs); + final selectionLabel = + selection.useFlood ? 'flood' : '${selection.hopCount} hops'; + appLogger.info( + 'Login routing: $selectionLabel', + tag: 'RoomLogin', + ); + bool? loginResult; + for (int attempt = 0; attempt < _maxAttempts; attempt++) { + if (!mounted) return; + setState(() { + _currentAttempt = attempt + 1; + }); + + appLogger.info( + 'Sending login attempt ${attempt + 1}/$_maxAttempts', + tag: 'RoomLogin', + ); + await _connector.sendFrame( + loginFrame, + ); + + loginResult = await _awaitLoginResponse(timeout); + if (loginResult == true) { + appLogger.info( + 'Login succeeded for ${room.name}', + tag: 'RoomLogin', + ); + break; + } + if (loginResult == false) { + appLogger.warn( + 'Login failed for ${room.name}', + tag: 'RoomLogin', + ); + throw Exception('Wrong password or node is unreachable'); + } + appLogger.warn( + 'Login attempt ${attempt + 1} timed out after ${timeoutSeconds}s', + tag: 'RoomLogin', + ); + } + + if (loginResult == null) { + appLogger.warn( + 'Login timed out for ${room.name}', + tag: 'RoomLogin', + ); + } + + if (loginResult == true) { + _connector.recordRepeaterPathResult(room, selection, true, null); + } else { + _connector.recordRepeaterPathResult(room, selection, false, null); + } + + if (loginResult != true) { + throw Exception('Wrong password or node is unreachable'); + } + + // If we got a response, login succeeded + // Save password if requested + if (_savePassword) { + await _storage.saveRepeaterPassword( + widget.room.publicKeyHex, password); + } else { + // Remove saved password if user unchecked the box + await _storage.removeRepeaterPassword(widget.room.publicKeyHex); + } + + if (mounted) { + Navigator.pop(context, password); + Future.microtask(() => widget.onLogin(password)); + } + } catch (e) { + final room = _resolveRepeater(_connector); + appLogger.warn( + 'Login error for ${room.name}: $e', + tag: 'RoomLogin', + ); + if (mounted) { + setState(() { + _isLoggingIn = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Login failed: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _awaitLoginResponse(Duration timeout) async { + final completer = Completer(); + Timer? timer; + StreamSubscription? subscription; + final targetPrefix = widget.room.publicKey.sublist(0, 6); + + subscription = _connector.receivedFrames.listen((frame) { + if (frame.isEmpty) return; + final code = frame[0]; + if (code != pushCodeLoginSuccess && code != pushCodeLoginFail) return; + if (frame.length < 8) return; + final prefix = frame.sublist(2, 8); + if (!listEquals(prefix, targetPrefix)) return; + + completer.complete(code == pushCodeLoginSuccess); + subscription?.cancel(); + timer?.cancel(); + }); + + timer = Timer(timeout, () { + if (!completer.isCompleted) { + completer.complete(null); + subscription?.cancel(); + } + }); + + final result = await completer.future; + timer.cancel(); + await subscription.cancel(); + return result; + } + + @override + Widget build(BuildContext context) { + final connector = context.watch(); + final repeater = _resolveRepeater(connector); + final isFloodMode = repeater.pathOverride == -1; + return AlertDialog( + title: Row( + children: [ + const Icon(Icons.group, color: Colors.purple), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Room Login'), + Text( + repeater.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + content: _isLoading + ? const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Enter the room password to access settings and status.', + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Password', + hintText: 'Enter password', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.lock), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + onSubmitted: (_) => _handleLogin(), + autofocus: _passwordController.text.isEmpty, + ), + const SizedBox(height: 12), + CheckboxListTile( + value: _savePassword, + onChanged: (value) { + setState(() { + _savePassword = value ?? false; + }); + }, + title: const Text( + 'Save password', + style: TextStyle(fontSize: 14), + ), + subtitle: const Text( + 'Password will be stored securely on this device', + style: TextStyle(fontSize: 12), + ), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + const Divider(), + Row( + children: [ + const Text( + 'Routing', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + const Spacer(), + PopupMenuButton( + icon: Icon(isFloodMode ? Icons.waves : Icons.route), + tooltip: 'Routing mode', + onSelected: (mode) async { + if (mode == 'flood') { + await connector.setPathOverride(repeater, pathLen: -1); + } else { + await connector.setPathOverride(repeater, pathLen: null); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'auto', + child: Row( + children: [ + Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null), + const SizedBox(width: 8), + Text( + 'Auto (use saved path)', + style: TextStyle( + fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ), + PopupMenuItem( + value: 'flood', + child: Row( + children: [ + Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null), + const SizedBox(width: 8), + Text( + 'Force Flood Mode', + style: TextStyle( + fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ), + ], + ), + ], + ), + const SizedBox(height: 4), + Text( + repeater.pathLabel, + style: const TextStyle(fontSize: 11, color: Colors.grey), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => PathManagementDialog.show(context, contact: repeater), + icon: const Icon(Icons.timeline, size: 18), + label: const Text('Manage Paths'), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + if (_isLoggingIn) + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: null, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + Text('Attempt $_currentAttempt/$_maxAttempts'), + ], + ), + ), + ) + else + FilledButton.icon( + onPressed: _isLoading ? null : _handleLogin, + icon: const Icon(Icons.login, size: 18), + label: const Text('Login'), + ), + ], + ); + } +}