update to current dev a50c0d0b2d

This commit is contained in:
ericz
2026-05-20 23:20:16 +02:00
parent 9ada4ea601
commit 3fe5cdf55d
14 changed files with 583 additions and 100 deletions
+1
View File
@@ -193,6 +193,7 @@ Devices are discovered by scanning for BLE advertisements with known MeshCore de
- `WisCore-` - `WisCore-`
- `HT-` - `HT-`
- `LowMesh_MC_` - `LowMesh_MC_`
- `NRF52`
New device prefixes can be added in `lib/connector/meshcore_uuids.dart`. New device prefixes can be added in `lib/connector/meshcore_uuids.dart`.
+72 -24
View File
@@ -302,6 +302,8 @@ class MeshCoreConnector extends ChangeNotifier {
final Map<String, int> _contactUnreadCount = {}; final Map<String, int> _contactUnreadCount = {};
final Map<String, RepeaterBatterySnapshot> _repeaterBatterySnapshots = {}; final Map<String, RepeaterBatterySnapshot> _repeaterBatterySnapshots = {};
bool _unreadStateLoaded = false; bool _unreadStateLoaded = false;
int _cachedContactsUnreadTotal = 0;
int _cachedChannelsUnreadTotal = 0;
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {}; final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
String? _activeContactKey; String? _activeContactKey;
int? _activeChannelIndex; int? _activeChannelIndex;
@@ -606,16 +608,42 @@ class MeshCoreConnector extends ChangeNotifier {
int getTotalUnreadCount() { int getTotalUnreadCount() {
if (!_unreadStateLoaded) return 0; if (!_unreadStateLoaded) return 0;
var total = 0; return getTotalContactsUnreadCount() + getTotalChannelsUnreadCount();
// Count unread contact messages }
for (final contact in _contacts) {
total += getUnreadCountForContact(contact); int getTotalContactsUnreadCount() {
} if (!_unreadStateLoaded) return 0;
// Count unread channel messages return _cachedContactsUnreadTotal;
for (final channelIndex in _channelMessages.keys) { }
total += getUnreadCountForChannelIndex(channelIndex);
} int getTotalChannelsUnreadCount() {
return total; if (!_unreadStateLoaded) return 0;
return _cachedChannelsUnreadTotal;
}
/// Recalculates both cached unread totals from scratch.
/// Called when unread state is first loaded.
void _recalculateCachedUnreadTotals() {
_recalculateCachedContactsUnreadTotal();
_recalculateCachedChannelsUnreadTotal();
}
void _recalculateCachedContactsUnreadTotal() {
int total = 0;
_contactUnreadCount.forEach((contactKeyHex, count) {
if (_shouldTrackUnreadForContactKey(contactKeyHex)) {
total += count;
}
});
_cachedContactsUnreadTotal = total;
}
void _recalculateCachedChannelsUnreadTotal() {
final allChannels = _channels.isNotEmpty ? _channels : _cachedChannels;
_cachedChannelsUnreadTotal = allChannels.fold(
0,
(total, ch) => total + ch.unreadCount,
);
} }
bool isChannelSmazEnabled(int channelIndex) { bool isChannelSmazEnabled(int channelIndex) {
@@ -649,11 +677,13 @@ class MeshCoreConnector extends ChangeNotifier {
..clear() ..clear()
..addAll(await _unreadStore.loadContactUnreadCount()); ..addAll(await _unreadStore.loadContactUnreadCount());
_unreadStateLoaded = true; _unreadStateLoaded = true;
_recalculateCachedUnreadTotals();
notifyListeners(); notifyListeners();
} }
Future<void> loadCachedChannels() async { Future<void> loadCachedChannels() async {
_cachedChannels = await _channelStore.loadChannels(); _cachedChannels = await _channelStore.loadChannels();
_recalculateCachedChannelsUnreadTotal();
} }
void setActiveContact(String? contactKeyHex) { void setActiveContact(String? contactKeyHex) {
@@ -680,6 +710,8 @@ class MeshCoreConnector extends ChangeNotifier {
final previousCount = _contactUnreadCount[contactKeyHex] ?? 0; final previousCount = _contactUnreadCount[contactKeyHex] ?? 0;
if (previousCount > 0) { if (previousCount > 0) {
_contactUnreadCount[contactKeyHex] = 0; _contactUnreadCount[contactKeyHex] = 0;
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - previousCount)
.clamp(0, _cachedContactsUnreadTotal);
_appDebugLogService?.info( _appDebugLogService?.info(
'Contact $contactKeyHex marked as read (was $previousCount unread)', 'Contact $contactKeyHex marked as read (was $previousCount unread)',
tag: 'Unread', tag: 'Unread',
@@ -721,6 +753,8 @@ class MeshCoreConnector extends ChangeNotifier {
if (channel != null && channel.unreadCount > 0) { if (channel != null && channel.unreadCount > 0) {
final previousCount = channel.unreadCount; final previousCount = channel.unreadCount;
channel.unreadCount = 0; channel.unreadCount = 0;
_cachedChannelsUnreadTotal = (_cachedChannelsUnreadTotal - previousCount)
.clamp(0, _cachedChannelsUnreadTotal);
_appDebugLogService?.info( _appDebugLogService?.info(
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)', 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)',
tag: 'Unread', tag: 'Unread',
@@ -3156,6 +3190,9 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_persistContacts()); unawaited(_persistContacts());
_conversations.remove(contact.publicKeyHex); _conversations.remove(contact.publicKeyHex);
_loadedConversationKeys.remove(contact.publicKeyHex); _loadedConversationKeys.remove(contact.publicKeyHex);
final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0;
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount)
.clamp(0, _cachedContactsUnreadTotal);
_contactUnreadCount.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount), Map<String, int>.from(_contactUnreadCount),
@@ -3549,6 +3586,7 @@ class MeshCoreConnector extends ChangeNotifier {
// Cache channels for offline use // Cache channels for offline use
_cachedChannels = List<Channel>.from(_channels); _cachedChannels = List<Channel>.from(_channels);
unawaited(_channelStore.saveChannels(_channels)); unawaited(_channelStore.saveChannels(_channels));
_recalculateCachedChannelsUnreadTotal();
// Apply ordering and notify UI // Apply ordering and notify UI
_applyChannelOrder(); _applyChannelOrder();
@@ -4101,6 +4139,9 @@ class MeshCoreConnector extends ChangeNotifier {
_handleDiscovery(contact, frame, noNotify: true, addActive: true); _handleDiscovery(contact, frame, noNotify: true, addActive: true);
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0;
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount)
.clamp(0, _cachedContactsUnreadTotal);
_contactUnreadCount.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount), Map<String, int>.from(_contactUnreadCount),
@@ -4191,6 +4232,9 @@ class MeshCoreConnector extends ChangeNotifier {
} }
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0;
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount)
.clamp(0, _cachedContactsUnreadTotal);
_contactUnreadCount.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount), Map<String, int>.from(_contactUnreadCount),
@@ -4464,17 +4508,13 @@ class MeshCoreConnector extends ChangeNotifier {
badgeCount: getTotalUnreadCount(), badgeCount: getTotalUnreadCount(),
); );
} else if (c?.type == advTypeRoom) { } else if (c?.type == advTypeRoom) {
// Room server messages include a 4-char prefix; strip it for notifications
final bodyText = msg.text.length > 4
? msg.text.substring(4)
: msg.text;
final resolvedText = final resolvedText =
(translationResult != null && (translationResult != null &&
translationResult.status == translationResult.status ==
MessageTranslationStatus.completed && MessageTranslationStatus.completed &&
translationResult.translatedText.trim().isNotEmpty) translationResult.translatedText.trim().isNotEmpty)
? translationResult.translatedText.trim() ? translationResult.translatedText.trim()
: bodyText.trim(); : msg.text.trim();
await _notificationService.showMessageNotification( await _notificationService.showMessageNotification(
contactName: c?.name ?? 'Unknown Room', contactName: c?.name ?? 'Unknown Room',
message: resolvedText, message: resolvedText,
@@ -4522,16 +4562,24 @@ class MeshCoreConnector extends ChangeNotifier {
timestampRaw * 1000, timestampRaw * 1000,
); );
if (txtType == 2) { final flags = txtType;
reader.skipBytes(4); // Skip extra 4 bytes for signed/plain variants final shiftedType = flags >> 2;
final rawType = flags;
final isSigned = shiftedType == txtTypeSigned || rawType == txtTypeSigned;
final Uint8List? roomAuthorPrefix;
if (isSigned) {
// Room-server pushed posts use signed/plain contact messages where this
// 4-byte "signature" field is actually the original author's pubkey
// prefix. Keep it as metadata; the text starts after these bytes.
roomAuthorPrefix = reader.readBytes(4);
} else {
roomAuthorPrefix = null;
} }
final msgText = reader.readCString(); final msgText = reader.readCString();
final flags = txtType; final isPlain =
final shiftedType = flags >> 2; shiftedType == txtTypePlain || rawType == txtTypePlain || isSigned;
final rawType = flags;
final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData; final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
if (!isPlain && !isCli) { if (!isPlain && !isCli) {
appLogger.warn( appLogger.warn(
@@ -4568,9 +4616,7 @@ class MeshCoreConnector extends ChangeNotifier {
status: MessageStatus.delivered, status: MessageStatus.delivered,
pathLength: pathLength == 0xFF ? 0 : pathLength, pathLength: pathLength == 0xFF ? 0 : pathLength,
pathBytes: Uint8List(0), pathBytes: Uint8List(0),
fourByteRoomContactKey: msgText.length >= 4 fourByteRoomContactKey: roomAuthorPrefix,
? Uint8List.fromList(msgText.substring(0, 4).codeUnits)
: null,
); );
} catch (e) { } catch (e) {
appLogger.warn('Error parsing contact direct message: $e'); appLogger.warn('Error parsing contact direct message: $e');
@@ -5247,6 +5293,7 @@ class MeshCoreConnector extends ChangeNotifier {
final channel = _findChannelByIndex(channelIndex); final channel = _findChannelByIndex(channelIndex);
if (channel != null) { if (channel != null) {
channel.unreadCount++; channel.unreadCount++;
_cachedChannelsUnreadTotal++;
_appDebugLogService?.info( _appDebugLogService?.info(
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}', 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}',
tag: 'Unread', tag: 'Unread',
@@ -5291,6 +5338,7 @@ class MeshCoreConnector extends ChangeNotifier {
final currentCount = _contactUnreadCount[contactKey] ?? 0; final currentCount = _contactUnreadCount[contactKey] ?? 0;
_contactUnreadCount[contactKey] = currentCount + 1; _contactUnreadCount[contactKey] = currentCount + 1;
_cachedContactsUnreadTotal++;
_appDebugLogService?.info( _appDebugLogService?.info(
'Contact $contactKey unread count incremented to ${currentCount + 1}', 'Contact $contactKey unread count incremented to ${currentCount + 1}',
tag: 'Unread', tag: 'Unread',
+1
View File
@@ -11,5 +11,6 @@ class MeshCoreUuids {
"Lilygo", "Lilygo",
"HT-", "HT-",
"LowMesh_MC_", "LowMesh_MC_",
"NRF52",
]; ];
} }
+34
View File
@@ -4,6 +4,9 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto; import 'package:crypto/crypto.dart' as crypto;
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import 'community.dart';
enum ChannelType { public, private, hashtag, communityPublic, communityHashtag }
class Channel { class Channel {
final int index; final int index;
@@ -111,5 +114,36 @@ class Channel {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
} }
static bool isCommunityChannel(ChannelType channelType) {
switch (channelType) {
case ChannelType.communityPublic:
case ChannelType.communityHashtag:
return true;
case ChannelType.public:
case ChannelType.private:
case ChannelType.hashtag:
return false;
}
}
static ChannelType getChannelType(
Channel channel,
CommunityPskIndex communityIndex,
) {
Community? community = communityIndex.getCommunityForChannel(channel);
if (community != null) {
if (Community.isCommunityPublicChannel(channel, community)) {
return ChannelType.communityPublic;
}
return ChannelType.communityHashtag;
}
if (channel.isPublicChannel) {
return ChannelType.public;
} else if (channel.name.startsWith('#')) {
return ChannelType.hashtag;
}
return ChannelType.private;
}
static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72'; static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72';
} }
+33
View File
@@ -4,6 +4,8 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto; import 'package:crypto/crypto.dart' as crypto;
import 'channel.dart';
/// Represents a community with a shared secret for deriving channel PSKs. /// Represents a community with a shared secret for deriving channel PSKs.
/// ///
/// A Community is a namespace with a shared secret K (32 random bytes), /// A Community is a namespace with a shared secret K (32 random bytes),
@@ -162,6 +164,12 @@ class Community {
return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim(); return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim();
} }
/// Returns true if this is the community's public channel
static bool isCommunityPublicChannel(Channel channel, Community community) {
final publicPsk = community.deriveCommunityPublicPsk();
return channel.pskHex == Channel.formatPskHex(publicPsk);
}
/// Add a hashtag channel to this community's list /// Add a hashtag channel to this community's list
Community addHashtagChannel(String hashtag) { Community addHashtagChannel(String hashtag) {
final normalized = _normalizeCommunityHashtag(hashtag); final normalized = _normalizeCommunityHashtag(hashtag);
@@ -237,3 +245,28 @@ class Community {
@override @override
int get hashCode => id.hashCode; int get hashCode => id.hashCode;
} }
class CommunityPskIndex {
// Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {};
void initialize(List<Community> communities) {
_pskToCommunity.clear();
for (final community in communities) {
// Map the community public channel PSK
final publicPsk = community.deriveCommunityPublicPsk();
_pskToCommunity[Channel.formatPskHex(publicPsk)] = community;
// Map all known hashtag channel PSKs
for (final hashtag in community.hashtagChannels) {
final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag);
_pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community;
}
}
}
/// Returns the community this channel belongs to, or null if not a community channel
Community? getCommunityForChannel(Channel channel) {
return _pskToCommunity[channel.pskHex];
}
}
+270
View File
@@ -181,6 +181,276 @@ class RadioSettings {
txPowerDbm: 14, txPowerDbm: 14,
), ),
), ),
(
'Russia Artyom (VVO)',
RadioSettings(
frequencyMHz: 864.281,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_6,
txPowerDbm: 20,
),
),
(
'Russia Biysk (BSK)',
RadioSettings(
frequencyMHz: 869.000,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'Russia Chelyabinsk (CEK)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_6,
txPowerDbm: 20,
),
),
(
'Russia Cherepovets (CEE)',
RadioSettings(
frequencyMHz: 868.570,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Irkutsk (IKT)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Ivanovo (IWA)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Izhevsk (IJK)',
RadioSettings(
frequencyMHz: 868.732,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Kaluga (KLF)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Kazan (KZN)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_6,
txPowerDbm: 20,
),
),
(
'Russia Khabarovsk (KHV)',
RadioSettings(
frequencyMHz: 864.281,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_6,
txPowerDbm: 20,
),
),
(
'Russia Kirov (KVX)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Lipetsk (LPK)',
RadioSettings(
frequencyMHz: 868.950,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf9,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Moscow (MOW)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Nizhny Novgorod (GOJ)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_6,
txPowerDbm: 20,
),
),
(
'Russia Novosibirsk (OVB)',
RadioSettings(
frequencyMHz: 869.000,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf9,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Rostov-on-Don (ROV)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf9,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Ryazan (RZN)',
RadioSettings(
frequencyMHz: 868.880,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf9,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'Russia Samara (KUF)',
RadioSettings(
frequencyMHz: 864.281,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Saratov (GSV)',
RadioSettings(
frequencyMHz: 864.281,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia St. Petersburg (LED)',
RadioSettings(
frequencyMHz: 868.856,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Tambov (TBW)',
RadioSettings(
frequencyMHz: 868.950,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf10,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'Russia Tula (TYA)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Tver (KLD)',
RadioSettings(
frequencyMHz: 869.169,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Ufa (UFA)',
RadioSettings(
frequencyMHz: 868.732,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
),
),
(
'Russia Volgograd (VOG)',
RadioSettings(
frequencyMHz: 869.525,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
(
'Russia Voronezh (VOZ)',
RadioSettings(
frequencyMHz: 868.731,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_6,
txPowerDbm: 20,
),
),
(
'Russia Yekaterinburg (SVX)',
RadioSettings(
frequencyMHz: 869.046,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_7,
txPowerDbm: 20,
),
),
( (
'Switzerland', 'Switzerland',
RadioSettings( RadioSettings(
+70 -7
View File
@@ -9,6 +9,8 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../models/community.dart';
import '../storage/community_store.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../helpers/chat_scroll_controller.dart'; import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
@@ -57,8 +59,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final ChatScrollController _scrollController = ChatScrollController(); final ChatScrollController _scrollController = ChatScrollController();
final FocusNode _textFieldFocusNode = FocusNode(); final FocusNode _textFieldFocusNode = FocusNode();
ChannelMessage? _replyingToMessage; ChannelMessage? _replyingToMessage;
final CommunityStore _communityStore = CommunityStore();
final CommunityPskIndex _communityIndex = CommunityPskIndex();
final Map<String, GlobalKey> _messageKeys = {}; final Map<String, GlobalKey> _messageKeys = {};
bool _isLoadingOlder = false; bool _isLoadingOlder = false;
bool _communitiesLoaded = false;
MeshCoreConnector? _connector; MeshCoreConnector? _connector;
DateTime? _lastChannelSendAt; DateTime? _lastChannelSendAt;
@@ -82,6 +87,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final idx = widget.channel.index; final idx = widget.channel.index;
final unread = widget.initialUnreadCount; final unread = widget.initialUnreadCount;
final messages = connector.getChannelMessages(widget.channel); final messages = connector.getChannelMessages(widget.channel);
_loadCommunities();
ChannelMessage? anchor; ChannelMessage? anchor;
if (unread > 0) { if (unread > 0) {
anchor = _findOldestUnreadChannelAnchor(messages, unread); anchor = _findOldestUnreadChannelAnchor(messages, unread);
@@ -108,6 +114,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}); });
} }
// TODO: Reload communities when returning from another screen
Future<void> _loadCommunities() async {
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
final communities = await _communityStore.loadCommunities();
if (mounted) {
setState(() {
_communityIndex.initialize(communities);
_communitiesLoaded = true;
});
}
}
ChannelMessage? _findOldestUnreadChannelAnchor( ChannelMessage? _findOldestUnreadChannelAnchor(
List<ChannelMessage> messages, List<ChannelMessage> messages,
int unreadCount, int unreadCount,
@@ -194,16 +213,63 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
Widget _channelIcon(Channel channel) {
// Determine icon based on channel type
final ChannelType channelType = Channel.getChannelType(
channel,
_communityIndex,
);
final bool isCommunityChannel = Channel.isCommunityChannel(channelType);
IconData icon;
switch (channelType) {
case ChannelType.communityPublic:
icon = Icons.groups;
case ChannelType.communityHashtag:
icon = Icons.tag;
case ChannelType.public:
icon = Icons.public;
case ChannelType.hashtag:
icon = Icons.tag;
case ChannelType.private:
icon = Icons.lock;
}
return Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: _communitiesLoaded
? Icon(icon, size: 20)
: SizedBox.square(dimension: 20),
),
if (isCommunityChannel)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.purple,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).cardColor,
width: 2,
),
),
child: const Icon(Icons.people, size: 8, color: Colors.white),
),
),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Row( title: Row(
children: [ children: [
Icon( _channelIcon(widget.channel),
widget.channel.isPublicChannel ? Icons.public : Icons.tag,
size: 20,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: Column(
@@ -1321,11 +1387,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
} }
void _showMessageActions(ChannelMessage message) { void _showMessageActions(ChannelMessage message) {
final settings = context.read<AppSettingsService>().settings;
final translationService = context.read<TranslationService>(); final translationService = context.read<TranslationService>();
final canTranslateMessage = final canTranslateMessage =
settings.translationEnabled &&
!settings.autoTranslateIncomingMessages &&
translationService.canTranslateIncoming( translationService.canTranslateIncoming(
text: message.text, text: message.text,
isCli: false, isCli: false,
+29 -55
View File
@@ -44,11 +44,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
with DisconnectNavigationMixin { with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final CommunityStore _communityStore = CommunityStore(); final CommunityStore _communityStore = CommunityStore();
Timer? _searchDebounce; final CommunityPskIndex _communityIndex = CommunityPskIndex();
List<Community> _communities = []; List<Community> _communities = [];
Timer? _searchDebounce;
// Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {};
ChannelMessageStore get _channelMessageStore => ChannelMessageStore(); ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
@@ -71,37 +69,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
if (mounted) { if (mounted) {
setState(() { setState(() {
_communities = communities; _communities = communities;
_buildPskCommunityMap(); _communityIndex.initialize(communities);
}); });
} }
} }
void _buildPskCommunityMap() {
_pskToCommunity.clear();
for (final community in _communities) {
// Map the community public channel PSK
final publicPsk = community.deriveCommunityPublicPsk();
_pskToCommunity[Channel.formatPskHex(publicPsk)] = community;
// Map all known hashtag channel PSKs
for (final hashtag in community.hashtagChannels) {
final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag);
_pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community;
}
}
}
/// Returns the community this channel belongs to, or null if not a community channel
Community? _getCommunityForChannel(Channel channel) {
return _pskToCommunity[channel.pskHex];
}
/// Returns true if this is the community's public channel
bool _isCommunityPublicChannel(Channel channel, Community community) {
final publicPsk = community.deriveCommunityPublicPsk();
return channel.pskHex == Channel.formatPskHex(publicPsk);
}
@override @override
void dispose() { void dispose() {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
@@ -360,6 +332,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
selectedIndex: 1, selectedIndex: 1,
onDestinationSelected: (index) => onDestinationSelected: (index) =>
_handleQuickSwitch(index, context), _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
), ),
), ),
), ),
@@ -375,37 +349,37 @@ class _ChannelsScreenState extends State<ChannelsScreen>
int? dragIndex, int? dragIndex,
}) { }) {
final unreadCount = connector.getUnreadCountForChannel(channel); final unreadCount = connector.getUnreadCountForChannel(channel);
final community = _getCommunityForChannel(channel);
final isCommunityChannel = community != null;
final isCommunityPublic =
isCommunityChannel && _isCommunityPublicChannel(channel, community);
// Determine icon and colors based on channel type // Determine icon and colors based on channel type
IconData icon; IconData icon;
Color iconColor; Color iconColor;
Color bgColor; Color bgColor;
final ChannelType channelType = Channel.getChannelType(
if (isCommunityChannel) { channel,
// Community channel styling _communityIndex,
iconColor = Colors.purple; );
bgColor = Colors.purple.withValues(alpha: 0.2); final bool isCommunityChannel = Channel.isCommunityChannel(channelType);
if (isCommunityPublic) { switch (channelType) {
case ChannelType.communityPublic:
icon = Icons.groups; icon = Icons.groups;
} else { iconColor = Colors.purple;
bgColor = Colors.purple.withValues(alpha: 0.2);
case ChannelType.communityHashtag:
icon = Icons.tag; icon = Icons.tag;
} iconColor = Colors.purple;
} else if (channel.isPublicChannel) { bgColor = Colors.purple.withValues(alpha: 0.2);
icon = Icons.public; case ChannelType.public:
iconColor = Colors.green; icon = Icons.public;
bgColor = Colors.green.withValues(alpha: 0.2); iconColor = Colors.green;
} else if (channel.name.startsWith('#')) { bgColor = Colors.green.withValues(alpha: 0.2);
icon = Icons.tag; case ChannelType.hashtag:
iconColor = Colors.blue; icon = Icons.tag;
bgColor = Colors.blue.withValues(alpha: 0.2); iconColor = Colors.blue;
} else { bgColor = Colors.blue.withValues(alpha: 0.2);
icon = Icons.lock; case ChannelType.private:
iconColor = Colors.blue; icon = Icons.lock;
bgColor = Colors.blue.withValues(alpha: 0.2); iconColor = Colors.blue;
bgColor = Colors.blue.withValues(alpha: 0.2);
} }
return Card( return Card(
+5 -10
View File
@@ -485,6 +485,8 @@ class _ChatScreenState extends State<ChatScreen> {
final message = reversedMessages[messageIndex]; final message = reversedMessages[messageIndex];
String fourByteHex = ''; String fourByteHex = '';
if (contact.type == advTypeRoom) { if (contact.type == advTypeRoom) {
// Room-server messages carry the original author's 4-byte prefix
// separately from message.text; use it only for resolving the name.
contact = _resolveContactFrom4Bytes( contact = _resolveContactFrom4Bytes(
connector, connector,
message.fourByteRoomContactKey.isEmpty message.fourByteRoomContactKey.isEmpty
@@ -509,7 +511,6 @@ class _ChatScreenState extends State<ChatScreen> {
? "${contact.name} [$fourByteHex]" ? "${contact.name} [$fourByteHex]"
: contact.name, : contact.name,
sourceId: widget.contact.publicKeyHex, sourceId: widget.contact.publicKeyHex,
isRoomServer: resolvedContact.type == advTypeRoom,
textScale: textScale, textScale: textScale,
onTap: () => _openMessagePath(message, contact), onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact), onLongPress: () => _showMessageActions(message, contact),
@@ -1577,11 +1578,8 @@ class _ChatScreenState extends State<ChatScreen> {
} }
void _showMessageActions(Message message, Contact contact) { void _showMessageActions(Message message, Contact contact) {
final settings = context.read<AppSettingsService>().settings;
final translationService = context.read<TranslationService>(); final translationService = context.read<TranslationService>();
final canTranslateMessage = final canTranslateMessage =
settings.translationEnabled &&
!settings.autoTranslateIncomingMessages &&
translationService.canTranslateIncoming( translationService.canTranslateIncoming(
text: message.text, text: message.text,
isCli: message.isCli, isCli: message.isCli,
@@ -1748,7 +1746,6 @@ class _ChatScreenState extends State<ChatScreen> {
class _MessageBubble extends StatelessWidget { class _MessageBubble extends StatelessWidget {
final Message message; final Message message;
final String senderName; final String senderName;
final bool isRoomServer;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onLongPress; final VoidCallback? onLongPress;
final void Function(Message message, String emoji)? onRetryReaction; final void Function(Message message, String emoji)? onRetryReaction;
@@ -1759,7 +1756,6 @@ class _MessageBubble extends StatelessWidget {
required this.message, required this.message,
required this.senderName, required this.senderName,
required this.sourceId, required this.sourceId,
required this.isRoomServer,
required this.textScale, required this.textScale,
this.onTap, this.onTap,
this.onLongPress, this.onLongPress,
@@ -1785,10 +1781,9 @@ class _MessageBubble extends StatelessWidget {
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
final metaColor = textColor.withValues(alpha: 0.7); final metaColor = textColor.withValues(alpha: 0.7);
const bodyFontSize = 14.0; const bodyFontSize = 14.0;
String messageText = message.text; // Do not strip room-server author bytes here: the parser stores them in
if (isRoomServer && !isOutgoing) { // fourByteRoomContactKey, so message.text is safe to render as-is.
messageText = message.text.substring(4.clamp(0, message.text.length)); final messageText = message.text;
}
final translatedDisplayText = final translatedDisplayText =
message.translatedText != null && message.translatedText != null &&
message.translatedText!.trim().isNotEmpty message.translatedText!.trim().isNotEmpty
+2
View File
@@ -430,6 +430,8 @@ class _ContactsScreenState extends State<ContactsScreen>
selectedIndex: 0, selectedIndex: 0,
onDestinationSelected: (index) => onDestinationSelected: (index) =>
_handleQuickSwitch(index, context), _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
), ),
), ),
), ),
@@ -539,6 +539,12 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
child: QuickSwitchBar( child: QuickSwitchBar(
selectedIndex: 2, selectedIndex: 2,
onDestinationSelected: (index) => _handleQuickSwitch(index, context), onDestinationSelected: (index) => _handleQuickSwitch(index, context),
contactsUnreadCount: context
.watch<MeshCoreConnector>()
.getTotalContactsUnreadCount(),
channelsUnreadCount: context
.watch<MeshCoreConnector>()
.getTotalChannelsUnreadCount(),
), ),
), ),
); );
+2
View File
@@ -676,6 +676,8 @@ class _MapScreenState extends State<MapScreen> {
selectedIndex: 2, selectedIndex: 2,
onDestinationSelected: (index) => onDestinationSelected: (index) =>
_handleQuickSwitch(index, context), _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
), ),
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
+14 -2
View File
@@ -388,7 +388,9 @@ class TranslationService extends ChangeNotifier {
if (targetLanguageCode == null || !_isPlainTextEligible(text)) { if (targetLanguageCode == null || !_isPlainTextEligible(text)) {
return null; return null;
} }
final detectedLanguageCode = await detectLanguage(text); final detectedLanguageCode = await detectLanguage(
_stripReplyInfoForDetection(text),
);
if (detectedLanguageCode != null && if (detectedLanguageCode != null &&
detectedLanguageCode == targetLanguageCode) { detectedLanguageCode == targetLanguageCode) {
return const TranslationResult( return const TranslationResult(
@@ -429,7 +431,9 @@ class TranslationService extends ChangeNotifier {
if (targetLanguageCode == null || !_isPlainTextEligible(text)) { if (targetLanguageCode == null || !_isPlainTextEligible(text)) {
return null; return null;
} }
final detectedLanguageCode = await detectLanguage(text); final detectedLanguageCode = await detectLanguage(
_stripReplyInfoForDetection(text),
);
if (detectedLanguageCode != null && if (detectedLanguageCode != null &&
detectedLanguageCode == targetLanguageCode) { detectedLanguageCode == targetLanguageCode) {
return const TranslationResult( return const TranslationResult(
@@ -470,6 +474,14 @@ class TranslationService extends ChangeNotifier {
} }
} }
String _stripReplyInfoForDetection(String text) {
final match = RegExp(
r'@\[([^\]]+)\]\s+(.+)$',
dotAll: true,
).firstMatch(text);
return match?.group(2) ?? text;
}
Future<String?> _translateText({ Future<String?> _translateText({
required String text, required String text,
required String targetLanguageCode, required String targetLanguageCode,
+44 -2
View File
@@ -6,11 +6,15 @@ import '../l10n/l10n.dart';
class QuickSwitchBar extends StatelessWidget { class QuickSwitchBar extends StatelessWidget {
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onDestinationSelected; final ValueChanged<int> onDestinationSelected;
final int contactsUnreadCount;
final int channelsUnreadCount;
const QuickSwitchBar({ const QuickSwitchBar({
super.key, super.key,
required this.selectedIndex, required this.selectedIndex,
required this.onDestinationSelected, required this.onDestinationSelected,
this.contactsUnreadCount = 0,
this.channelsUnreadCount = 0,
}); });
@override @override
@@ -62,15 +66,30 @@ class QuickSwitchBar extends StatelessWidget {
onDestinationSelected: onDestinationSelected, onDestinationSelected: onDestinationSelected,
destinations: [ destinations: [
NavigationDestination( NavigationDestination(
icon: const Icon(Icons.people_outline), icon: _buildIconWithBadge(
const Icon(Icons.people_outline),
contactsUnreadCount,
),
selectedIcon: _buildIconWithBadge(
const Icon(Icons.people),
contactsUnreadCount,
),
label: context.l10n.nav_contacts, label: context.l10n.nav_contacts,
), ),
NavigationDestination( NavigationDestination(
icon: const Icon(Icons.tag), icon: _buildIconWithBadge(
const Icon(Icons.tag),
channelsUnreadCount,
),
selectedIcon: _buildIconWithBadge(
const Icon(Icons.tag),
channelsUnreadCount,
),
label: context.l10n.nav_channels, label: context.l10n.nav_channels,
), ),
NavigationDestination( NavigationDestination(
icon: const Icon(Icons.map_outlined), icon: const Icon(Icons.map_outlined),
selectedIcon: const Icon(Icons.map),
label: context.l10n.nav_map, label: context.l10n.nav_map,
), ),
], ],
@@ -81,4 +100,27 @@ class QuickSwitchBar extends StatelessWidget {
), ),
); );
} }
Widget _buildIconWithBadge(Icon icon, int count) {
if (count <= 0) return icon;
return Stack(
clipBehavior: Clip.none,
children: [
icon,
Positioned(
right: -2,
top: -2,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
),
),
),
],
);
}
} }