diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 6f36c854..b83a6ba0 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -2797,11 +2797,15 @@ class MeshCoreConnector extends ChangeNotifier { try { if (!isConnected) return; + final mode = _pathHashByteWidth - 1; + final encodedPathLen = (pathLen >= 0 && pathLen != 0xFF) + ? (pathLen | (mode << 6)) + : pathLen; await sendFrame( buildUpdateContactPathFrame( contact.publicKey, customPath, - pathLen, + encodedPathLen, type: contact.type, flags: contact.flags, name: contact.name, @@ -2858,11 +2862,15 @@ class MeshCoreConnector extends ChangeNotifier { : (updatedFlags & ~contactFlagTeleEnv)) : updatedFlags; + final mode = _pathHashByteWidth - 1; + final encodedPathLen = (latestContact.pathLength >= 0 && latestContact.pathLength != 0xFF) + ? (latestContact.pathLength | (mode << 6)) + : latestContact.pathLength; await sendFrame( buildUpdateContactPathFrame( latestContact.publicKey, latestContact.path, - latestContact.pathLength, + encodedPathLen, type: latestContact.type, flags: updatedFlags, name: latestContact.name, @@ -3198,11 +3206,15 @@ class MeshCoreConnector extends ChangeNotifier { Future importDiscoveredContact(Contact contact) async { if (!isConnected) return; + final mode = _pathHashByteWidth - 1; + final encodedPathLen = (contact.pathLength >= 0 && contact.pathLength != 0xFF) + ? (contact.pathLength | (mode << 6)) + : contact.pathLength; await sendFrame( buildUpdateContactPathFrame( contact.publicKey, contact.path, - contact.pathLength, + encodedPathLen, type: contact.type, flags: contact.flags, name: contact.name, diff --git a/lib/models/contact.dart b/lib/models/contact.dart index a5808ed3..d727210c 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -166,8 +166,18 @@ class Contact { final type = reader.readByte(); final flags = reader.readByte(); final pathLen = reader.readByte(); - final safePathLen = pathLen > 0 - ? (pathLen > maxPathSize ? maxPathSize : pathLen) + int hopCount = 0; + int byteLen = 0; + if (pathLen == 0xFF) { + hopCount = -1; + } else { + final mode = (pathLen & 0xC0) >> 6; + hopCount = pathLen & 0x3F; + final width = mode + 1; + byteLen = hopCount * width; + } + final safePathLen = byteLen > 0 + ? (byteLen > maxPathSize ? maxPathSize : byteLen) : 0; final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen); final name = reader.readCStringGreedy(maxNameSize); @@ -213,7 +223,7 @@ class Contact { name: name.isEmpty ? 'Unknown' : name, type: type, flags: flags, - pathLength: (pathLen == 0xFF || pathLen > maxPathSize) ? -1 : pathLen, + pathLength: hopCount, path: pathBytes, latitude: lat, longitude: lon, diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index 7883d885..fa4152d2 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -88,15 +88,40 @@ class ContactStore { final lastSeenMs = json['lastSeen'] as int? ?? 0; final lastMessageMs = json['lastMessageAt'] as int?; final lastModifiedMs = json['lastModified'] as int?; + + final rawPathLength = json['pathLength'] as int? ?? -1; + final rawPath = json['path'] != null + ? Uint8List.fromList(base64Decode(json['path'] as String)) + : Uint8List(0); + + int decodedPathLength = rawPathLength; + Uint8List decodedPath = rawPath; + + if (rawPathLength == 0xFF || rawPathLength < 0) { + decodedPathLength = -1; + decodedPath = Uint8List(0); + } else if (rawPathLength >= 64) { + final mode = (rawPathLength & 0xC0) >> 6; + final hopCount = rawPathLength & 0x3F; + final width = mode + 1; + final byteLen = hopCount * width; + decodedPathLength = hopCount; + if (byteLen <= rawPath.length) { + decodedPath = rawPath.sublist(0, byteLen); + } else { + decodedPath = Uint8List(0); + } + } else if (rawPathLength == 0) { + decodedPath = Uint8List(0); + } + return Contact( publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), name: json['name'] as String? ?? 'Unknown', type: json['type'] as int? ?? 0, flags: json['flags'] as int? ?? 0, - pathLength: json['pathLength'] as int? ?? -1, - path: json['path'] != null - ? Uint8List.fromList(base64Decode(json['path'] as String)) - : Uint8List(0), + pathLength: decodedPathLength, + path: decodedPath, pathOverride: json['pathOverride'] as int?, pathOverrideBytes: json['pathOverrideBytes'] != null ? Uint8List.fromList( diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 3d237681..96aaba9c 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -113,6 +113,35 @@ class MessageStore { final decodedText = isCli ? rawText : (Smaz.tryDecodePrefixed(rawText) ?? rawText); + + final rawPathLength = json['pathLength'] as int?; + final rawPathBytes = json['pathBytes'] != null + ? Uint8List.fromList(base64Decode(json['pathBytes'] as String)) + : Uint8List(0); + + int? decodedPathLength = rawPathLength; + Uint8List decodedPathBytes = rawPathBytes; + + if (rawPathLength != null) { + if (rawPathLength == 0xFF || rawPathLength < 0) { + decodedPathLength = -1; + decodedPathBytes = Uint8List(0); + } else if (rawPathLength >= 64) { + final mode = (rawPathLength & 0xC0) >> 6; + final hopCount = rawPathLength & 0x3F; + final width = mode + 1; + final byteLen = hopCount * width; + decodedPathLength = hopCount; + if (byteLen <= rawPathBytes.length) { + decodedPathBytes = rawPathBytes.sublist(0, byteLen); + } else { + decodedPathBytes = Uint8List(0); + } + } else if (rawPathLength == 0) { + decodedPathBytes = Uint8List(0); + } + } + return Message( senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)), text: decodedText, @@ -138,10 +167,8 @@ class MessageStore { ? DateTime.fromMillisecondsSinceEpoch(json['deliveredAt'] as int) : null, tripTimeMs: json['tripTimeMs'] as int?, - pathLength: json['pathLength'] as int?, - pathBytes: json['pathBytes'] != null - ? Uint8List.fromList(base64Decode(json['pathBytes'] as String)) - : Uint8List(0), + pathLength: decodedPathLength, + pathBytes: decodedPathBytes, reactions: (json['reactions'] as Map?)?.map( (key, value) => MapEntry(key, value as int), diff --git a/test/models/model_changes_test.dart b/test/models/model_changes_test.dart index a7d1a659..cc14187a 100644 --- a/test/models/model_changes_test.dart +++ b/test/models/model_changes_test.dart @@ -2,11 +2,16 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/models/channel_message.dart'; import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/models/message.dart'; import 'package:meshcore_open/models/path_history.dart'; import 'package:meshcore_open/models/app_settings.dart'; +import 'package:meshcore_open/storage/prefs_manager.dart'; +import 'package:meshcore_open/storage/contact_store.dart'; +import 'package:meshcore_open/storage/message_store.dart'; // Builds a valid contact frame with the given pathLen and optional overrides. // Frame layout: [respCode(1)][pubKey(32)][type(1)][flags(1)][pathLen(1)][path(64)][name(32)][timestamp(4)][lat(4)][lon(4)] @@ -147,11 +152,11 @@ void main() { expect(contact!.pathLength, equals(1)); }); - test('pathLen == 64 (maxPathSize) → pathLength == 64', () { - final frame = _buildContactFrame(pathLen: maxPathSize); + test('pathLen == 64 (mode 1, 0 hops) → pathLength == 0', () { + final frame = _buildContactFrame(pathLen: 64); final contact = Contact.fromFrame(frame); expect(contact, isNotNull); - expect(contact!.pathLength, equals(maxPathSize)); + expect(contact!.pathLength, equals(0)); }); test('pathLen == 0xFF → pathLength == -1 (flood)', () { @@ -161,11 +166,18 @@ void main() { expect(contact!.pathLength, equals(-1)); }); - test('pathLen == 65 (over maxPathSize) → pathLength == -1 (flood)', () { + test('pathLen == 65 (mode 1, 1 hop) → pathLength == 1', () { final frame = _buildContactFrame(pathLen: 65); final contact = Contact.fromFrame(frame); expect(contact, isNotNull); - expect(contact!.pathLength, equals(-1)); + expect(contact!.pathLength, equals(1)); + }); + + test('pathLen == 129 (mode 2, 1 hop) → pathLength == 1', () { + final frame = _buildContactFrame(pathLen: 129); + final contact = Contact.fromFrame(frame); + expect(contact, isNotNull); + expect(contact!.pathLength, equals(1)); }); }); @@ -452,4 +464,94 @@ void main() { expect(updated.maxRouteWeight, equals(settings.maxRouteWeight)); }); }); + + group('Storage migration — multi-byte paths compatibility', () { + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await PrefsManager.initialize(); + }); + + test('ContactStore decodes and migrates legacy mode-encoded paths', () async { + final store = ContactStore()..publicKeyHex = '1234567890'; + + // Let's create a contact with legacy 64 path length (mode 1, 0 hops) + final rawPath = Uint8List(64); // 64 bytes of zeroes + final contactJson = [ + { + 'publicKey': base64Encode(Uint8List(32)..[0] = 0xAA), + 'name': 'LegacyNode', + 'type': 2, + 'flags': 0, + 'pathLength': 64, // encoded pathLength (mode 1, 0 hops) + 'path': base64Encode(rawPath), + 'lastSeen': DateTime.now().millisecondsSinceEpoch, + 'lastMessageAt': DateTime.now().millisecondsSinceEpoch, + 'isActive': true, + } + ]; + + final prefs = PrefsManager.instance; + await prefs.setString(store.keyFor, jsonEncode(contactJson)); + + final contacts = await store.loadContacts(); + expect(contacts, hasLength(1)); + expect(contacts.first.pathLength, equals(0)); + expect(contacts.first.path, isEmpty); + }); + + test('ContactStore decodes and migrates legacy mode-1 paths with hops', () async { + final store = ContactStore()..publicKeyHex = '1234567890'; + + // Contact with legacy 65 path length (mode 1, 1 hop) + final rawPath = Uint8List(64)..[0] = 0xBB..[1] = 0xCC; + final contactJson = [ + { + 'publicKey': base64Encode(Uint8List(32)..[0] = 0xAA), + 'name': 'LegacyNode2', + 'type': 2, + 'flags': 0, + 'pathLength': 65, // encoded pathLength (mode 1, 1 hop) + 'path': base64Encode(rawPath), + 'lastSeen': DateTime.now().millisecondsSinceEpoch, + 'lastMessageAt': DateTime.now().millisecondsSinceEpoch, + 'isActive': true, + } + ]; + + final prefs = PrefsManager.instance; + await prefs.setString(store.keyFor, jsonEncode(contactJson)); + + final contacts = await store.loadContacts(); + expect(contacts, hasLength(1)); + expect(contacts.first.pathLength, equals(1)); + expect(contacts.first.path, equals(Uint8List.fromList([0xBB, 0xCC]))); + }); + + test('MessageStore decodes and migrates legacy mode-encoded message paths', () async { + final store = MessageStore()..publicKeyHex = '1234567890'; + final contactKeyHex = pubKeyToHex(Uint8List(32)..[0] = 0xAA); + + final rawPath = Uint8List(64); + final messageJson = [ + { + 'senderKey': base64Encode(Uint8List(32)..[0] = 0xAA), + 'text': 'Hello', + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'isOutgoing': false, + 'status': 2, // delivered + 'messageId': 'msg_1', + 'pathLength': 64, // encoded pathLength (mode 1, 0 hops) + 'pathBytes': base64Encode(rawPath), + } + ]; + + final prefs = PrefsManager.instance; + await prefs.setString('${store.keyFor}$contactKeyHex', jsonEncode(messageJson)); + + final messages = await store.loadMessages(contactKeyHex); + expect(messages, hasLength(1)); + expect(messages.first.pathLength, equals(0)); + expect(messages.first.pathBytes, isEmpty); + }); + }); }