From 97addf7ba6a07263265a814b9ccca9327bf81e45 Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 10 Mar 2026 09:06:30 +0100 Subject: [PATCH] Hide last seen and tweak channels --- lib/models/contact.dart | 3 +- lib/providers/app_provider.dart | 20 +- .../helpers/raw_session_retransmit.dart | 8 +- lib/screens/contacts_tab.dart | 479 +++++++++++++++--- .../messages/image_message_bubble.dart | 11 +- .../messages/voice_message_bubble.dart | 16 +- pubspec.lock | 2 +- test/models/contact_route_codec_test.dart | 2 +- .../helpers/raw_session_retransmit_test.dart | 57 +++ 9 files changed, 466 insertions(+), 132 deletions(-) diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 6da0af8..4473fdd 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -240,6 +240,5 @@ extension ContactLocalization on Contact { return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes'; } - bool get routeSupportsLegacyRawTransport => - routeHasPath && routeSignedPathLen >= 0; + bool get routeSupportsLegacyRawTransport => routeHasPath; } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 7fe28df..883c7e8 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1974,14 +1974,13 @@ class AppProvider with ChangeNotifier { if (requester == null || !requester.routeHasPath || requester.routeHopCount > _maxDirectPayloadHops || - !requester.routeSupportsLegacyRawTransport || requester.outPath.isEmpty) { return; } unawaited( connectionProvider.sendRawVoicePacket( contactPath: requester.outPath, - contactPathLen: requester.routeSignedPathLen, + contactPathLen: requester.routeEncodedPathLen, payload: availability.encodeBinary(), ), ); @@ -2051,7 +2050,7 @@ class AppProvider with ChangeNotifier { for (final peer in peers) { await connectionProvider.sendRawVoicePacket( contactPath: peer.outPath, - contactPathLen: peer.routeSignedPathLen, + contactPathLen: peer.routeEncodedPathLen, payload: request.encodeBinary(), ); } @@ -2074,7 +2073,6 @@ class AppProvider with ChangeNotifier { if (responder == null || !responder.routeHasPath || responder.routeHopCount > _maxDirectPayloadHops || - !responder.routeSupportsLegacyRawTransport || responder.outPath.isEmpty) { continue; } @@ -2166,7 +2164,7 @@ class AppProvider with ChangeNotifier { await connectionProvider.sendRawVoicePacket( contactPath: target.outPath, - contactPathLen: target.routeSignedPathLen, + contactPathLen: target.routeEncodedPathLen, payload: payload, ); return true; @@ -2407,9 +2405,6 @@ class AppProvider with ChangeNotifier { if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) { return false; } - if (!target.routeSupportsLegacyRawTransport) { - return false; - } if (target.outPath.isEmpty) { return false; } @@ -2442,7 +2437,7 @@ class AppProvider with ChangeNotifier { ); await connectionProvider.sendRawVoicePacket( contactPath: target.outPath, - contactPathLen: target.routeSignedPathLen, + contactPathLen: target.routeEncodedPathLen, payload: RawRouteProbeRequest( nonce: nonce, requesterKey6: requesterKey6, @@ -2470,7 +2465,7 @@ class AppProvider with ChangeNotifier { if (target.publicKeyHex.isNotEmpty) { return 'pk:${target.publicKeyHex}'; } - return 'name:${target.advName}:${target.routeSignedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; + return 'name:${target.advName}:${target.routeEncodedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; } void _handleRawRouteProbeRequest(RawRouteProbeRequest request) { @@ -2488,9 +2483,6 @@ class AppProvider with ChangeNotifier { ); return; } - if (!requester.routeSupportsLegacyRawTransport) { - return; - } if (requester.outPath.isEmpty) { return; } @@ -2500,7 +2492,7 @@ class AppProvider with ChangeNotifier { unawaited( connectionProvider.sendRawVoicePacket( contactPath: requester.outPath, - contactPathLen: requester.routeSignedPathLen, + contactPathLen: requester.routeEncodedPathLen, payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(), ), ); diff --git a/lib/providers/helpers/raw_session_retransmit.dart b/lib/providers/helpers/raw_session_retransmit.dart index 1d22d8c..246ae9f 100644 --- a/lib/providers/helpers/raw_session_retransmit.dart +++ b/lib/providers/helpers/raw_session_retransmit.dart @@ -38,12 +38,6 @@ Future serveCachedSessionFragments({ ); return false; } - if (!requester.routeSupportsLegacyRawTransport) { - debugPrint( - '⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client', - ); - return false; - } if (requester.outPath.isEmpty) { debugPrint( '⚠️ [$providerLabel] ${requester.advName} has empty outPath payload', @@ -64,7 +58,7 @@ Future serveCachedSessionFragments({ try { await sendRawPacket( contactPath: requester.outPath, - contactPathLen: requester.routeSignedPathLen, + contactPathLen: requester.routeEncodedPathLen, payload: encodeBinary(fragment), ); servedCount++; diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 8ac194b..ce2c0fd 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -7,7 +7,10 @@ import '../models/contact.dart'; import '../providers/contacts_provider.dart'; import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; +import '../providers/messages_provider.dart'; import '../utils/contact_grouping.dart'; +import '../utils/avatar_label_helper.dart'; +import '../widgets/common/contact_avatar.dart'; import '../widgets/contacts/contact_tile.dart'; import '../widgets/contacts/add_channel_dialog.dart'; @@ -28,7 +31,11 @@ class ContactsTab extends StatefulWidget { class _ContactsTabState extends State { Position? _currentPosition; final Set _resolvingAdvertKeys = {}; - ContactSortMode _sortMode = ContactSortMode.lastSeen; + final Map _sortModes = { + ContactSection.teamMembers: ContactSortMode.lastSeen, + ContactSection.repeaters: ContactSortMode.lastSeen, + ContactSection.rooms: ContactSortMode.lastSeen, + }; @override void initState() { @@ -127,11 +134,20 @@ class _ContactsTabState extends State { return l10n.daysAgo(diff.inDays); } - List _sortContacts(List contacts) { + List _sortContacts(List contacts, ContactSection section) { final sorted = List.from(contacts); + if (section == ContactSection.channels) { + sorted.sort( + (a, b) => + a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), + ); + return sorted; + } + + final sortMode = _sortModes[section] ?? ContactSortMode.lastSeen; sorted.sort((a, b) { - if (_sortMode == ContactSortMode.distance) { + if (sortMode == ContactSortMode.distance) { final distanceA = _distanceFromCurrentPosition(a); final distanceB = _distanceFromCurrentPosition(b); @@ -212,10 +228,23 @@ class _ContactsTabState extends State { return Scaffold( body: Consumer( builder: (context, contactsProvider, child) { - final chatContacts = _sortContacts(contactsProvider.chatContacts); - final repeaters = _sortContacts(contactsProvider.repeaters); - final rooms = _sortContacts(contactsProvider.rooms); - final channels = _sortContacts(contactsProvider.channels); + final messagesProvider = context.watch(); + final chatContacts = _sortContacts( + contactsProvider.chatContacts, + ContactSection.teamMembers, + ); + final repeaters = _sortContacts( + contactsProvider.repeaters, + ContactSection.repeaters, + ); + final rooms = _sortContacts( + contactsProvider.rooms, + ContactSection.rooms, + ); + final channels = _sortContacts( + contactsProvider.channels, + ContactSection.channels, + ); final pendingAdverts = contactsProvider.pendingAdverts; // Check if there are any displayable contacts @@ -257,17 +286,6 @@ class _ContactsTabState extends State { child: ListView( padding: const EdgeInsets.all(8), children: [ - _SortModeSwitcher( - sortMode: _sortMode, - lastSeenLabel: l10n.lastSeen, - distanceLabel: l10n.distance, - onChanged: (sortMode) { - setState(() { - _sortMode = sortMode; - }); - }, - ), - // Pending adverts (public key only; quick resolve) if (pendingAdverts.isNotEmpty) ...[ _SectionHeader( @@ -295,6 +313,10 @@ class _ContactsTabState extends State { title: l10n.teamMembers, count: chatContacts.length, icon: Icons.people, + trailing: _buildSortMenu( + context, + ContactSection.teamMembers, + ), ), ..._buildContactSectionItems(chatContacts), const Divider(height: 32), @@ -306,6 +328,7 @@ class _ContactsTabState extends State { title: l10n.repeaters, count: repeaters.length, icon: Icons.router, + trailing: _buildSortMenu(context, ContactSection.repeaters), ), ..._buildContactSectionItems(repeaters), const Divider(height: 32), @@ -317,6 +340,7 @@ class _ContactsTabState extends State { title: l10n.rooms, count: rooms.length, icon: Icons.tag, + trailing: _buildSortMenu(context, ContactSection.rooms), ), ..._buildContactSectionItems(rooms), const Divider(height: 32), @@ -329,7 +353,14 @@ class _ContactsTabState extends State { icon: Icons.broadcast_on_personal, ), if (channels.isNotEmpty) ...[ - ..._buildContactSectionItems(channels), + ...channels.map( + (channel) => _ChannelActivityCard( + channel: channel, + messagesProvider: messagesProvider, + contactsProvider: contactsProvider, + onNavigateToMessages: widget.onNavigateToMessages, + ), + ), ], // Add Channel Button (visible in both simple and advanced mode, only show when connected) @@ -385,10 +416,70 @@ class _ContactsTabState extends State { ); }).toList(); } + + Widget _buildSortMenu(BuildContext context, ContactSection section) { + final l10n = AppLocalizations.of(context)!; + final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen; + final colorScheme = Theme.of(context).colorScheme; + + return PopupMenuButton( + tooltip: 'Sort', + initialValue: selectedMode, + onSelected: (sortMode) { + setState(() { + _sortModes[section] = sortMode; + }); + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: ContactSortMode.lastSeen, + child: Row( + children: [ + Icon( + Icons.schedule, + size: 18, + color: selectedMode == ContactSortMode.lastSeen + ? colorScheme.primary + : null, + ), + const SizedBox(width: 8), + Text(l10n.lastSeen), + ], + ), + ), + PopupMenuItem( + value: ContactSortMode.distance, + child: Row( + children: [ + Icon( + Icons.near_me, + size: 18, + color: selectedMode == ContactSortMode.distance + ? colorScheme.primary + : null, + ), + const SizedBox(width: 8), + Text(l10n.distance), + ], + ), + ), + ], + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.more_horiz, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + ), + ); + } } enum ContactSortMode { lastSeen, distance } +enum ContactSection { teamMembers, repeaters, rooms, channels } + class _PendingAdvertTile extends StatelessWidget { final PendingAdvert advert; final String subtitle; @@ -433,11 +524,13 @@ class _SectionHeader extends StatelessWidget { final String title; final int count; final IconData icon; + final Widget? trailing; const _SectionHeader({ required this.title, required this.count, required this.icon, + this.trailing, }); @override @@ -466,6 +559,7 @@ class _SectionHeader extends StatelessWidget { style: Theme.of(context).textTheme.labelSmall, ), ), + if (trailing != null) ...[const Spacer(), trailing!], ], ), ); @@ -504,43 +598,42 @@ class _InferredContactGroupCard extends StatelessWidget { color: colorScheme.outlineVariant.withValues(alpha: 0.35), ), ), - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.folder_copy_outlined, - size: 18, - color: colorScheme.primary, - ), - const SizedBox(width: 8), - Text( + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + childrenPadding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + initiallyExpanded: true, + leading: Icon( + Icons.folder_copy_outlined, + size: 18, + color: colorScheme.primary, + ), + title: Row( + children: [ + Expanded( + child: Text( label, style: Theme.of( context, ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800), ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - contacts.length.toString(), - style: Theme.of(context).textTheme.labelSmall, - ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(999), ), - ], - ), - const SizedBox(height: 10), + child: Text( + contacts.length.toString(), + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ], + ), + children: [ ...contacts.map( (contact) => ContactTile( contact: contact, @@ -558,48 +651,268 @@ class _InferredContactGroupCard extends StatelessWidget { } } -class _SortModeSwitcher extends StatelessWidget { - final ContactSortMode sortMode; - final String lastSeenLabel; - final String distanceLabel; - final ValueChanged onChanged; +class _ChannelActivityCard extends StatelessWidget { + final Contact channel; + final MessagesProvider messagesProvider; + final ContactsProvider contactsProvider; + final VoidCallback? onNavigateToMessages; - const _SortModeSwitcher({ - required this.sortMode, - required this.lastSeenLabel, - required this.distanceLabel, - required this.onChanged, + const _ChannelActivityCard({ + required this.channel, + required this.messagesProvider, + required this.contactsProvider, + required this.onNavigateToMessages, }); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Align( - alignment: Alignment.centerLeft, - child: SegmentedButton( - segments: [ - ButtonSegment( - value: ContactSortMode.lastSeen, - label: Text(lastSeenLabel), - icon: const Icon(Icons.schedule), - ), - ButtonSegment( - value: ContactSortMode.distance, - label: Text(distanceLabel), - icon: const Icon(Icons.near_me), - ), + final colorScheme = Theme.of(context).colorScheme; + final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + final channelMessages = messagesProvider.getMessagesForChannel(channelIdx) + ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); + final participantNames = []; + for (final message in channelMessages) { + final senderName = message.senderName?.trim(); + if (senderName == null || senderName.isEmpty) continue; + if (!participantNames.contains(senderName)) { + participantNames.add(senderName); + } + } + return Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.surfaceContainerLow, + colorScheme.tertiaryContainer.withValues(alpha: 0.45), ], - selected: {sortMode}, - onSelectionChanged: (selection) { - final selected = selection.isEmpty ? null : selection.first; - if (selected != null) { - onChanged(selected); - } + ), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () async { + messagesProvider.navigateToDestination( + 'channel', + recipientPublicKeyHex: channel.publicKeyHex, + ); + onNavigateToMessages?.call(); }, - showSelectedIcon: false, + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ContactAvatar(contact: channel, radius: 24), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + channel.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 10), + if (participantNames.isNotEmpty) + _ExpandableParticipantStack( + names: participantNames, + contactForName: _findParticipantContact, + ) + else + Text( + 'No recent chatters', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MetricChip( + icon: Icons.forum_outlined, + label: '${channelMessages.length}', + helper: 'messages', + ), + _MetricChip( + icon: Icons.group_outlined, + label: '${participantNames.length}', + helper: 'active', + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Contact? _findParticipantContact(String name) { + for (final contact in contactsProvider.contacts) { + if (!contact.isChannel && contact.advName == name) { + return contact; + } + } + return null; + } +} + +class _ExpandableParticipantStack extends StatefulWidget { + final List names; + final Contact? Function(String name) contactForName; + + const _ExpandableParticipantStack({ + required this.names, + required this.contactForName, + }); + + @override + State<_ExpandableParticipantStack> createState() => + _ExpandableParticipantStackState(); +} + +class _ExpandableParticipantStackState + extends State<_ExpandableParticipantStack> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final visibleNames = _expanded + ? widget.names + : widget.names.take(4).toList(); + final spacing = _expanded ? 24.0 : 18.0; + const avatarSize = 28.0; + final width = avatarSize + (visibleNames.length - 1) * spacing; + + return GestureDetector( + onTap: widget.names.length > 4 + ? () { + setState(() { + _expanded = !_expanded; + }); + } + : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + width: width, + height: avatarSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < visibleNames.length; i++) + Positioned( + left: i * spacing, + child: _ParticipantAvatar( + name: visibleNames[i], + contact: widget.contactForName(visibleNames[i]), + ), + ), + ], ), ), ); } } + +class _ParticipantAvatar extends StatelessWidget { + final String name; + final Contact? contact; + + const _ParticipantAvatar({required this.name, required this.contact}); + + @override + Widget build(BuildContext context) { + if (contact != null) { + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.surface, + width: 2, + ), + ), + child: ContactAvatar(contact: contact!, radius: 14), + ); + } + + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + shape: BoxShape.circle, + border: Border.all(color: colorScheme.surface, width: 2), + ), + alignment: Alignment.center, + child: Text( + AvatarLabelHelper.buildLabel(name), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onTertiaryContainer, + ), + ), + ); + } +} + +class _MetricChip extends StatelessWidget { + final IconData icon; + final String label; + final String helper; + + const _MetricChip({ + required this.icon, + required this.label, + required this.helper, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 5), + Text( + label, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(width: 4), + Text( + helper, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart index a5003ac..6d0daef 100644 --- a/lib/widgets/messages/image_message_bubble.dart +++ b/lib/widgets/messages/image_message_bubble.dart @@ -403,15 +403,6 @@ class _ImageMessageBubbleState extends State { } } - if (!sender.routeSupportsLegacyRawTransport) { - _clearRequestState(); - await _showBlockingAlert( - 'Cannot fetch image', - 'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.', - ); - return; - } - if (sender.routeHopCount >= 2) { _showToast( 'Image fetch over ${sender.routeHopCount} hops may take a while.', @@ -462,7 +453,7 @@ class _ImageMessageBubbleState extends State { ); await conn.sendRawVoicePacket( contactPath: sender.outPath, - contactPathLen: sender.routeSignedPathLen, + contactPathLen: sender.routeEncodedPathLen, payload: payload, ); } catch (_) { diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index 840c809..86ebfc2 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -366,15 +366,6 @@ class _VoiceMessageBubbleState extends State { } } - if (!sender.routeSupportsLegacyRawTransport) { - _clearRequestState(); - await _showBlockingAlert( - 'Cannot fetch voice', - 'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.', - ); - return; - } - if (sender.routeHopCount >= 2) { _showToast( 'Voice fetch over ${sender.routeHopCount} hops may take a while.', @@ -415,10 +406,7 @@ class _VoiceMessageBubbleState extends State { missingIndices: missing, requesterKey6: requesterKey6, ) - : VoiceFetchRequest( - sessionId: sessionId, - requesterKey6: requesterKey6, - ); + : VoiceFetchRequest(sessionId: sessionId, requesterKey6: requesterKey6); try { debugPrint( @@ -426,7 +414,7 @@ class _VoiceMessageBubbleState extends State { ); await connectionProvider.sendRawVoicePacket( contactPath: sender.outPath, - contactPathLen: sender.routeSignedPathLen, + contactPathLen: sender.routeEncodedPathLen, payload: request.encodeBinary(), ); } catch (_) { diff --git a/pubspec.lock b/pubspec.lock index bf11dc1..4d84f84 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -796,7 +796,7 @@ packages: description: path: "." ref: main - resolved-ref: "84b133069cbf9ce8e261ed13ac3051388f355878" + resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460 url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0" diff --git a/test/models/contact_route_codec_test.dart b/test/models/contact_route_codec_test.dart index 23289c5..f06eae9 100644 --- a/test/models/contact_route_codec_test.dart +++ b/test/models/contact_route_codec_test.dart @@ -104,7 +104,7 @@ void main() { expect(contact.routeHashSize, 3); expect(contact.routeHopCount, 2); expect(contact.routeCanonicalText, 'AABBCC,DDEEFF'); - expect(contact.routeSupportsLegacyRawTransport, isFalse); + expect(contact.routeSupportsLegacyRawTransport, isTrue); }); test('treats -1 as unknown route', () { diff --git a/test/providers/helpers/raw_session_retransmit_test.dart b/test/providers/helpers/raw_session_retransmit_test.dart index 512cd76..d4e1390 100644 --- a/test/providers/helpers/raw_session_retransmit_test.dart +++ b/test/providers/helpers/raw_session_retransmit_test.dart @@ -47,6 +47,63 @@ void main() { expect(ok, isFalse); }); + test('returns false when requester has no learned path', () async { + final ok = await serveCachedSessionFragments<_Fragment>( + providerLabel: 'TestProvider', + sessionId: 'deadbeef', + requester: _buildContact(outPathLen: -1), + fragments: [ + _Fragment(0, Uint8List.fromList([1])), + ], + maxDirectPayloadHops: 3, + indexOf: (f) => f.index, + encodeBinary: (f) => f.payload, + sendRawPacket: + ({ + required contactPath, + required contactPathLen, + required payload, + }) async {}, + ); + + expect(ok, isFalse); + }); + + test('returns false when requester path payload is empty', () async { + final requester = Contact( + publicKey: Uint8List.fromList(List.generate(32, (i) => i)), + type: ContactType.chat, + flags: 0, + outPathLen: 1, + outPath: Uint8List(0), + advName: 'Requester', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + final ok = await serveCachedSessionFragments<_Fragment>( + providerLabel: 'TestProvider', + sessionId: 'deadbeef', + requester: requester, + fragments: [ + _Fragment(0, Uint8List.fromList([1])), + ], + maxDirectPayloadHops: 3, + indexOf: (f) => f.index, + encodeBinary: (f) => f.payload, + sendRawPacket: + ({ + required contactPath, + required contactPathLen, + required payload, + }) async {}, + ); + + expect(ok, isFalse); + }); + test('sends only requested indices', () async { final sent = []; final ok = await serveCachedSessionFragments<_Fragment>(