You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
273 lines
8.8 KiB
273 lines
8.8 KiB
import 'dart:typed_data';
|
|
import 'package:meshcore_open/utils/app_logger.dart';
|
|
|
|
import '../connector/meshcore_protocol.dart';
|
|
|
|
class Contact {
|
|
final Uint8List publicKey;
|
|
final String name;
|
|
final int type;
|
|
final int flags;
|
|
|
|
/// Hop count for [path]: -1 = flood, 0+ = number of hops.
|
|
///
|
|
/// This is the firmware path-len field's low 6 bits, which firmware defines
|
|
/// as a hash COUNT, not a byte length (`src/Packet.h:79-84`). It was
|
|
/// previously treated as a byte count, which truncated every path at widths
|
|
/// above 1. (#309)
|
|
final int pathLength;
|
|
|
|
/// Bytes per hop hash in [path] (1..3), from the path-len byte's high 2 bits.
|
|
///
|
|
/// Carried per-path rather than read from the connector's global width, so a
|
|
/// stored path can never be re-sliced at a width it was not captured at.
|
|
final int pathHashWidth;
|
|
|
|
final Uint8List path; // Path bytes from device (pathLength * pathHashWidth)
|
|
final int?
|
|
pathOverride; // User's path override: -1 = force flood, null = auto
|
|
final Uint8List? pathOverrideBytes; // User's path override bytes
|
|
final double? latitude;
|
|
final double? longitude;
|
|
final DateTime lastSeen;
|
|
final DateTime lastMessageAt;
|
|
final DateTime? lastModified;
|
|
final bool isActive;
|
|
final bool wasPulled;
|
|
final Uint8List? rawPacket;
|
|
|
|
Contact({
|
|
required this.publicKey,
|
|
required this.name,
|
|
required this.type,
|
|
this.flags = 0,
|
|
required this.pathLength,
|
|
this.pathHashWidth = 1,
|
|
required this.path,
|
|
this.pathOverride,
|
|
this.pathOverrideBytes,
|
|
this.latitude,
|
|
this.longitude,
|
|
required this.lastSeen,
|
|
this.lastModified,
|
|
DateTime? lastMessageAt,
|
|
this.isActive = true,
|
|
this.wasPulled = false,
|
|
this.rawPacket,
|
|
}) : lastMessageAt = lastMessageAt ?? lastSeen;
|
|
|
|
String get publicKeyHex => pubKeyToHex(publicKey);
|
|
|
|
/// Non-localized type label, intended for logs and non-UI exports
|
|
/// (e.g. GPX). For UI use the `typeLabel(l10n)` extension in
|
|
/// `lib/l10n/contact_localization.dart`.
|
|
String get typeLabelRaw {
|
|
switch (type) {
|
|
case advTypeChat:
|
|
return 'Chat';
|
|
case advTypeRepeater:
|
|
return 'Repeater';
|
|
case advTypeRoom:
|
|
return 'Room';
|
|
case advTypeSensor:
|
|
return 'Sensor';
|
|
default:
|
|
return 'Unknown';
|
|
}
|
|
}
|
|
|
|
bool get hasLocation {
|
|
const double epsilon = 1e-6;
|
|
final lat = latitude ?? 0.0;
|
|
final lon = longitude ?? 0.0;
|
|
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
|
|
lat >= -90.0 &&
|
|
lat <= 90.0 &&
|
|
lon >= -180.0 &&
|
|
lon <= 180.0;
|
|
}
|
|
|
|
bool get isFavorite => (flags & contactFlagFavorite) != 0;
|
|
|
|
Contact copyWith({
|
|
Uint8List? publicKey,
|
|
String? name,
|
|
int? type,
|
|
int? flags,
|
|
int? pathLength,
|
|
int? pathHashWidth,
|
|
Uint8List? path,
|
|
int? pathOverride,
|
|
Uint8List? pathOverrideBytes,
|
|
bool clearPathOverride = false,
|
|
double? latitude,
|
|
double? longitude,
|
|
DateTime? lastSeen,
|
|
DateTime? lastMessageAt,
|
|
DateTime? lastModified,
|
|
bool? isActive,
|
|
Uint8List? rawPacket,
|
|
}) {
|
|
return Contact(
|
|
publicKey: publicKey ?? this.publicKey,
|
|
name: name ?? this.name,
|
|
type: type ?? this.type,
|
|
flags: flags ?? this.flags,
|
|
pathLength: pathLength ?? this.pathLength,
|
|
pathHashWidth: pathHashWidth ?? this.pathHashWidth,
|
|
path: path ?? this.path,
|
|
pathOverride: clearPathOverride
|
|
? null
|
|
: (pathOverride ?? this.pathOverride),
|
|
pathOverrideBytes: clearPathOverride
|
|
? null
|
|
: (pathOverrideBytes ?? this.pathOverrideBytes),
|
|
latitude: latitude ?? this.latitude,
|
|
longitude: longitude ?? this.longitude,
|
|
lastSeen: lastSeen ?? this.lastSeen,
|
|
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
|
|
lastModified: lastModified ?? this.lastModified,
|
|
isActive: isActive ?? this.isActive,
|
|
rawPacket: rawPacket ?? this.rawPacket,
|
|
);
|
|
}
|
|
|
|
/// Formats path bytes into comma-separated hex groups of [hashByteWidth] bytes.
|
|
String pathFormattedIdList(int hashByteWidth) {
|
|
final pathBytes = pathBytesForDisplay;
|
|
if (pathBytes.isEmpty) return '';
|
|
final w = hashByteWidth.clamp(1, 8);
|
|
final parts = <String>[];
|
|
for (int i = 0; i < pathBytes.length; i += w) {
|
|
final end = (i + w) <= pathBytes.length ? (i + w) : pathBytes.length;
|
|
final chunk = pathBytes.sublist(i, end);
|
|
parts.add(
|
|
chunk
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
|
.join(),
|
|
);
|
|
}
|
|
return parts.join(',');
|
|
}
|
|
|
|
/// Groups by this path's own captured width, not a global or legacy default.
|
|
String get pathIdList => pathFormattedIdList(pathHashWidth);
|
|
|
|
String get shortPubKeyHex {
|
|
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
|
|
}
|
|
|
|
Uint8List get pathBytesForDisplay {
|
|
if (pathOverride != null) {
|
|
if (pathOverride! < 0) return Uint8List(0);
|
|
return pathOverrideBytes ?? Uint8List(0);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
static Contact? fromFrame(Uint8List data) {
|
|
if (data.isEmpty) return null;
|
|
final reader = BufferReader(data);
|
|
try {
|
|
final respCode = reader.readByte();
|
|
if (respCode != respCodeContact && respCode != pushCodeNewAdvert) {
|
|
return null;
|
|
}
|
|
final pubKey = reader.readBytes(pubKeySize);
|
|
|
|
// Guard: reject contacts with zeroed or mostly-zeroed public keys
|
|
// (indicates corrupt flash storage on the firmware side)
|
|
final zeroCount = pubKey.where((b) => b == 0).length;
|
|
if (zeroCount > pubKeySize ~/ 2) return null;
|
|
|
|
final type = reader.readByte();
|
|
final flags = reader.readByte();
|
|
final pathLen = reader.readByte();
|
|
// The firmware path-len byte packs hash size in the high 2 bits and hash
|
|
// COUNT (hops) in the low 6; the byte length is count * size
|
|
// (`src/Packet.h:79-84`). 0xFF stays the flood sentinel.
|
|
//
|
|
// This previously read `count` BYTES, so at 2-byte width it kept half of
|
|
// every path and discarded the rest — the truncation behind #240's failed
|
|
// repeater logins. The width is taken from the path itself rather than the
|
|
// connector's global width, so a path is always sliced at the width it was
|
|
// captured at. (#309)
|
|
final isFlood = pathLen == 0xFF;
|
|
final hopCount = isFlood ? -1 : pathHopCount(pathLen);
|
|
final hashWidth = isFlood ? 1 : pathHashSizeBytes(pathLen);
|
|
final byteLen = isFlood ? 0 : (hopCount * hashWidth);
|
|
final safePathLen = byteLen.clamp(0, maxPathSize);
|
|
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
|
|
final name = reader.readCStringGreedy(maxNameSize);
|
|
|
|
// Guard: reject contacts with non-printable names (corrupt flash data)
|
|
if (name.isNotEmpty &&
|
|
name.codeUnits.every((c) => c < 0x20 || c == 0xFFFD)) {
|
|
return null;
|
|
}
|
|
|
|
// mandatory last_advert_timestamp
|
|
final lastAdvertTimestamp = reader.readUInt32LE();
|
|
|
|
double? lat, lon;
|
|
DateTime? lastModified;
|
|
if (reader.remaining >= 12) {
|
|
final latRaw = reader.readInt32LE();
|
|
final lonRaw = reader.readInt32LE();
|
|
final lastModRaw = reader.readUInt32LE();
|
|
// TODO: should this be &&?
|
|
if (latRaw != 0 || lonRaw != 0) {
|
|
lat = latRaw / 1e6;
|
|
lon = lonRaw / 1e6;
|
|
}
|
|
if (lastModRaw != 0) {
|
|
lastModified = DateTime.fromMillisecondsSinceEpoch(lastModRaw * 1000);
|
|
}
|
|
} else if (reader.remaining >= 8) {
|
|
// Old layout: gps without lastmod
|
|
final latRaw = reader.readInt32LE();
|
|
final lonRaw = reader.readInt32LE();
|
|
if (latRaw != 0 || lonRaw != 0) {
|
|
lat = latRaw / 1e6;
|
|
lon = lonRaw / 1e6;
|
|
}
|
|
appLogger.info(
|
|
'Contact ${pubKeyToHex(pubKey).substring(0, 8)} has gps but no lastmod (legacy firmware layout)',
|
|
);
|
|
}
|
|
|
|
return Contact(
|
|
publicKey: pubKey,
|
|
name: name.isEmpty ? 'Unknown' : name,
|
|
type: type,
|
|
flags: flags,
|
|
pathLength:
|
|
hopCount, // hop count from the low 6 bits; -1 = flood (#309)
|
|
pathHashWidth: hashWidth, // bytes/hop from the high 2 bits (#309)
|
|
path: pathBytes,
|
|
latitude: lat,
|
|
longitude: lon,
|
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(
|
|
lastAdvertTimestamp * 1000,
|
|
),
|
|
lastModified: lastModified,
|
|
isActive: true,
|
|
rawPacket: null,
|
|
);
|
|
} catch (e) {
|
|
appLogger.error('Failed to parse contact frame: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is Contact && publicKeyHex == other.publicKeyHex;
|
|
|
|
@override
|
|
int get hashCode => publicKeyHex.hashCode;
|
|
bool get teleBaseEnabled => (flags & contactFlagTeleBase) != 0;
|
|
bool get teleLocEnabled => (flags & contactFlagTeleLoc) != 0;
|
|
bool get teleEnvEnabled => (flags & contactFlagTeleEnv) != 0;
|
|
}
|