diff --git a/lib/ble/ble_responses.dart b/lib/ble/ble_responses.dart index 6c1abab..02d2ff9 100644 --- a/lib/ble/ble_responses.dart +++ b/lib/ble/ble_responses.dart @@ -156,6 +156,11 @@ class ChannelMessageReceivedResponse extends BleResponse { final String text; final int snr; final int pathLength; + + /// Wire txt_type from the V3 response -- also packed into the original + /// over-the-air ciphertext's plaintext header, needed to reconstruct that + /// plaintext exactly for raw-frame correlation (see channel_crypto.dart). + final int txtType; final bool isFromSelf; ChannelMessageReceivedResponse({ @@ -166,6 +171,7 @@ class ChannelMessageReceivedResponse extends BleResponse { required this.text, required this.snr, required this.pathLength, + required this.txtType, required this.isFromSelf, }) : super(BleConstants.respChannelMsgRecvV3); } @@ -529,7 +535,7 @@ class BleResponseParser { reader.readByte(); // reserved2 final channelIndex = reader.readByte(); final pathLength = reader.readByte() & 0x3F; // bits 5–0 are hop count - final txtType = reader.readByte(); // txt_type (ignored for now) + final txtType = reader.readByte(); final timestamp = reader.readUInt32LE(); // Read remaining bytes as text @@ -543,6 +549,7 @@ class BleResponseParser { text: text, snr: snr, pathLength: pathLength, + txtType: txtType, isFromSelf: false, // V3 doesn't indicate this ); } diff --git a/lib/ble/raw_packet_log.dart b/lib/ble/raw_packet_log.dart new file mode 100644 index 0000000..109c2f6 --- /dev/null +++ b/lib/ble/raw_packet_log.dart @@ -0,0 +1,174 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 + +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart' as crypto; + +/// A parsed raw radio packet, sourced from a PUSH_LOG_RX_DATA (0x88) BLE +/// frame: `[0x88][snr int8][rssi int8][raw mesh packet]`. The raw packet +/// structure itself matches MeshCore's over-the-air format (see +/// docs/packet_format.md in the firmware repo): +/// `[header][transport_codes?][path_length][path][payload]`. +/// +/// This is the data firmware's own decoded message responses (V3) never +/// expose: the actual per-hop path bytes and true RSSI, plus one entry per +/// physical reception rather than one per logical (deduped) message. +class RawPacketFrame { + /// Raw int8 SNR value scaled by 4 (matches Messages.snr's encoding from + /// the V3 message responses -- same firmware-side scaling, no conversion + /// needed to compare the two). + final int snr; + + /// Raw RSSI in dBm. + final int rssi; + + final int routeType; + final int payloadType; + final int payloadVersion; + + /// Raw wire path_length byte (hash-size mode packed with hop count). + final int pathByte; + + /// Raw hop-hash bytes, hop_count * hash_size long. + final Uint8List pathBytes; + + /// Remaining packet bytes after path -- ciphertext for TXT_MSG/GRP_TXT. + final Uint8List payload; + + /// SHA-256(payload_type byte + payload bytes)[:16 hex, uppercase] -- + /// matches MeshCore firmware's Packet::calculatePacketHash(). Identical + /// across flood-relayed copies of the same logical packet regardless of + /// path, since it excludes routing/path bytes entirely. + final String packetHash; + + final int receivedAtMs; + + RawPacketFrame({ + required this.snr, + required this.rssi, + required this.routeType, + required this.payloadType, + required this.payloadVersion, + required this.pathByte, + required this.pathBytes, + required this.payload, + required this.packetHash, + required this.receivedAtMs, + }); +} + +/// Payload type of a text/channel-text packet, per docs/packet_format.md. +const int payloadTypeTxtMsg = 0x02; +const int payloadTypeGrpTxt = 0x05; + +/// Parses a PUSH_LOG_RX_DATA (0x88) BLE frame into a [RawPacketFrame]. +/// Returns null for malformed/too-short frames. +RawPacketFrame? parseRawPacketLogFrame(Uint8List frame, {DateTime? now}) { + // frame[0] is the 0x88 push code itself (caller strips or includes it -- + // this function expects it included, matching the raw BLE payload). + // Minimum viable length: code + snr + rssi + header + path_byte. + if (frame.length < 5) return null; + + final snr = frame[1].toSigned(8); + final rssi = frame[2].toSigned(8); + final packet = frame.sublist(3); + + return parseRawMeshPacket(packet, snr: snr, rssi: rssi, now: now); +} + +/// Parses the raw mesh packet bytes (without the 0x88/snr/rssi BLE prefix). +/// Exposed separately so it can be unit-tested against known packet bytes. +RawPacketFrame? parseRawMeshPacket( + Uint8List packet, { + required int snr, + required int rssi, + DateTime? now, +}) { + if (packet.length < 2) return null; + + var offset = 0; + final header = packet[offset++]; + final routeType = header & 0x03; + final payloadType = (header >> 2) & 0x0F; + final payloadVersion = (header >> 6) & 0x03; + + // ROUTE_TYPE_TRANSPORT_FLOOD (0x00) and ROUTE_TYPE_TRANSPORT_DIRECT (0x03) + // carry 4 bytes of transport codes before the path byte. + if (routeType == 0x00 || routeType == 0x03) { + if (packet.length < offset + 4) return null; + offset += 4; + } + + if (packet.length < offset + 1) return null; + final pathByte = packet[offset++]; + final hashSize = ((pathByte & 0xC0) >> 6) + 1; + final hopCount = pathByte & 0x3F; + final pathByteLen = hopCount * hashSize; + + if (packet.length < offset + pathByteLen) return null; + final pathBytes = packet.sublist(offset, offset + pathByteLen); + offset += pathByteLen; + + final payload = packet.sublist(offset); + + final hashInput = Uint8List(1 + payload.length) + ..[0] = payloadType + ..setRange(1, 1 + payload.length, payload); + final packetHash = crypto.sha256 + .convert(hashInput) + .toString() + .substring(0, 16) + .toUpperCase(); + + return RawPacketFrame( + snr: snr, + rssi: rssi, + routeType: routeType, + payloadType: payloadType, + payloadVersion: payloadVersion, + pathByte: pathByte, + pathBytes: pathBytes, + payload: payload, + packetHash: packetHash, + receivedAtMs: (now ?? DateTime.now()).millisecondsSinceEpoch, + ); +} + +/// Short-lived buffer of recently-seen raw packets, used to correlate a +/// decoded chat message (arriving via the normal, separate sync path) back +/// to the raw reception(s) it came from. Bounded by both age and count so +/// it can't grow unbounded on a busy channel. +class RawPacketLog { + static const Duration _maxAge = Duration(seconds: 60); + static const int _maxEntries = 200; + + final List _frames = []; + + void add(RawPacketFrame frame) { + _frames.add(frame); + _prune(); + } + + /// All buffered frames matching [packetHash], oldest first. + List byPacketHash(String packetHash) { + _prune(); + return _frames.where((f) => f.packetHash == packetHash).toList(); + } + + /// All buffered frames of a given payload type, for brute-force matching + /// against a known plaintext (caller re-encrypts and compares payload + /// bytes directly rather than relying on this method to filter by hash). + List byPayloadType(int payloadType) { + _prune(); + return _frames.where((f) => f.payloadType == payloadType).toList(); + } + + void _prune() { + final cutoff = DateTime.now().subtract(_maxAge).millisecondsSinceEpoch; + _frames.removeWhere((f) => f.receivedAtMs < cutoff); + if (_frames.length > _maxEntries) { + _frames.removeRange(0, _frames.length - _maxEntries); + } + } +} diff --git a/lib/database/daos/contacts_dao.dart b/lib/database/daos/contacts_dao.dart index 8d49d44..1620380 100644 --- a/lib/database/daos/contacts_dao.dart +++ b/lib/database/daos/contacts_dao.dart @@ -112,6 +112,49 @@ class ContactsDao extends DatabaseAccessor return null; } + /// Get all contacts matching the first [prefixLength] bytes of their + /// public key, unlike [getContactByPublicKeyPrefix] which only returns + /// the first match. Used to detect and surface ambiguity when resolving + /// a short raw path-hop hash (1-3 bytes) against known contacts, since a + /// short prefix can legitimately collide between multiple contacts. + Future> getContactsByPublicKeyPrefix( + Uint8List prefix, { + int prefixLength = 6, + String? companionKey, + }) async { + if (prefix.isEmpty) return const []; + if (prefixLength <= 0) return const []; + + final effectivePrefixLength = + prefixLength > prefix.length ? prefix.length : prefixLength; + + final query = select(contacts); + if (companionKey != null && companionKey.isNotEmpty) { + query.where((t) => t.companionDeviceKey.equals(companionKey)); + } + + final allContacts = await query.get(); + final matches = []; + for (final contact in allContacts) { + final pk = contact.publicKey; + if (pk.length < effectivePrefixLength) continue; + + bool isMatch = true; + for (int i = 0; i < effectivePrefixLength; i++) { + if (pk[i] != prefix[i]) { + isMatch = false; + break; + } + } + + if (isMatch) { + matches.add(contact); + } + } + + return matches; + } + /// Get a single contact by hash (derived from full public key) /// Returns first match if multiple contacts have same hash Future getContactByHash(int hash) { @@ -433,8 +476,7 @@ class ContactsDao extends DatabaseAccessor ); final controller = StreamController(); - final contactsSub = - watchContactsByCompanion(companionKey).listen((_) { + final contactsSub = watchContactsByCompanion(companionKey).listen((_) { if (!controller.isClosed) controller.add(null); }); final messagesSub = db.messagesDao.watchMessageCount().listen((_) { diff --git a/lib/database/daos/message_paths_dao.dart b/lib/database/daos/message_paths_dao.dart new file mode 100644 index 0000000..9b2569c --- /dev/null +++ b/lib/database/daos/message_paths_dao.dart @@ -0,0 +1,46 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 +// http://creativecommons.org/licenses/by-nc-sa/4.0/ +// +// This file is part of TEAM-Flutter. +// Non-commercial use only. See LICENSE file for details. + +import 'package:drift/drift.dart'; +import '../database.dart'; +import '../tables.dart'; + +part 'message_paths_dao.g.dart'; + +/// DAO for per-message radio path observations (see MessagePaths table doc). +@DriftAccessor(tables: [MessagePaths]) +class MessagePathsDao extends DatabaseAccessor + with _$MessagePathsDaoMixin { + MessagePathsDao(super.db); + + /// Get all observed paths for a message, oldest first. + Future> getPathsByMessage(String messageId) { + return (select(messagePaths) + ..where((t) => t.messageId.equals(messageId)) + ..orderBy([ + (t) => + OrderingTerm(expression: t.receivedAt, mode: OrderingMode.asc), + ])) + .get(); + } + + /// Insert a newly-correlated path observation for a message. Silently + /// ignores a conflict on the (messageId, pathBytes) unique constraint -- + /// correlation can run concurrently for the same message (delivered via + /// PUSH and then again via a later sync), so this is the atomic + /// alternative to an application-side check-then-insert race. + Future insertPath(MessagePathsCompanion path) { + return into(messagePaths).insert(path, mode: InsertMode.insertOrIgnore); + } + + /// Delete paths older than a timestamp (retention cleanup). + Future deletePathsOlderThan(int timestamp) { + return (delete(messagePaths) + ..where((t) => t.receivedAt.isSmallerThanValue(timestamp))) + .go(); + } +} diff --git a/lib/database/daos/message_paths_dao.g.dart b/lib/database/daos/message_paths_dao.g.dart new file mode 100644 index 0000000..6c070d4 --- /dev/null +++ b/lib/database/daos/message_paths_dao.g.dart @@ -0,0 +1,8 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message_paths_dao.dart'; + +// ignore_for_file: type=lint +mixin _$MessagePathsDaoMixin on DatabaseAccessor { + $MessagePathsTable get messagePaths => attachedDatabase.messagePaths; +} diff --git a/lib/database/database.dart b/lib/database/database.dart index 3de9b72..6e7d4ae 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -15,6 +15,7 @@ import 'tables.dart'; import 'daos/contacts_dao.dart'; import 'daos/channels_dao.dart'; import 'daos/messages_dao.dart'; +import 'daos/message_paths_dao.dart'; import 'daos/waypoints_dao.dart'; import 'daos/ack_records_dao.dart'; import 'daos/companion_devices_dao.dart'; @@ -44,6 +45,7 @@ typedef AckRecord = AckRecordData; Contacts, Channels, Messages, + MessagePaths, Waypoints, CompanionDevices, ContactDisplayStates, @@ -56,6 +58,7 @@ typedef AckRecord = AckRecordData; ContactsDao, ChannelsDao, MessagesDao, + MessagePathsDao, WaypointsDao, AckRecordsDao, CompanionDevicesDao, @@ -70,7 +73,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(QueryExecutor executor) : super(executor); @override - int get schemaVersion => 8; + int get schemaVersion => 9; @override MigrationStrategy get migration => MigrationStrategy( @@ -161,7 +164,18 @@ class AppDatabase extends _$AppDatabase { await customStatement( 'ALTER TABLE channels ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0', ); - print('[Migration] v7->v8: importedOverlayMaps, favorites, channel notification mode'); + print( + '[Migration] v7->v8: importedOverlayMaps, favorites, channel notification mode'); + } + + // Migration from schema version 8 to 9: per-message hop count/SNR, + // and the message_paths table for multi-path routing detail. + if (from <= 8 && to >= 9) { + await m.addColumn(messages, messages.hopCount); + await m.addColumn(messages, messages.snr); + await m.createTable(messagePaths); + print( + '[Migration] v8->v9: added hopCount/snr to messages, created message_paths table'); } }, ); diff --git a/lib/database/database.g.dart b/lib/database/database.g.dart index 056f521..6b1c9b2 100644 --- a/lib/database/database.g.dart +++ b/lib/database/database.g.dart @@ -1547,6 +1547,17 @@ class $MessagesTable extends Messages late final GeneratedColumn companionDeviceKey = GeneratedColumn('companion_device_key', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _hopCountMeta = + const VerificationMeta('hopCount'); + @override + late final GeneratedColumn hopCount = GeneratedColumn( + 'hop_count', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _snrMeta = const VerificationMeta('snr'); + @override + late final GeneratedColumn snr = GeneratedColumn( + 'snr', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); @override List get $columns => [ id, @@ -1562,7 +1573,9 @@ class $MessagesTable extends Messages attempt, isSentByMe, isRead, - companionDeviceKey + companionDeviceKey, + hopCount, + snr ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1659,6 +1672,14 @@ class $MessagesTable extends Messages companionDeviceKey.isAcceptableOrUnknown( data['companion_device_key']!, _companionDeviceKeyMeta)); } + if (data.containsKey('hop_count')) { + context.handle(_hopCountMeta, + hopCount.isAcceptableOrUnknown(data['hop_count']!, _hopCountMeta)); + } + if (data.containsKey('snr')) { + context.handle( + _snrMeta, snr.isAcceptableOrUnknown(data['snr']!, _snrMeta)); + } return context; } @@ -1696,6 +1717,10 @@ class $MessagesTable extends Messages .read(DriftSqlType.bool, data['${effectivePrefix}is_read'])!, companionDeviceKey: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}companion_device_key']), + hopCount: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}hop_count']), + snr: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}snr']), ); } @@ -1720,6 +1745,8 @@ class MessageData extends DataClass implements Insertable { final bool isSentByMe; final bool isRead; final String? companionDeviceKey; + final int? hopCount; + final int? snr; const MessageData( {required this.id, required this.senderId, @@ -1734,7 +1761,9 @@ class MessageData extends DataClass implements Insertable { required this.attempt, required this.isSentByMe, required this.isRead, - this.companionDeviceKey}); + this.companionDeviceKey, + this.hopCount, + this.snr}); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -1758,6 +1787,12 @@ class MessageData extends DataClass implements Insertable { if (!nullToAbsent || companionDeviceKey != null) { map['companion_device_key'] = Variable(companionDeviceKey); } + if (!nullToAbsent || hopCount != null) { + map['hop_count'] = Variable(hopCount); + } + if (!nullToAbsent || snr != null) { + map['snr'] = Variable(snr); + } return map; } @@ -1783,6 +1818,10 @@ class MessageData extends DataClass implements Insertable { companionDeviceKey: companionDeviceKey == null && nullToAbsent ? const Value.absent() : Value(companionDeviceKey), + hopCount: hopCount == null && nullToAbsent + ? const Value.absent() + : Value(hopCount), + snr: snr == null && nullToAbsent ? const Value.absent() : Value(snr), ); } @@ -1805,6 +1844,8 @@ class MessageData extends DataClass implements Insertable { isRead: serializer.fromJson(json['isRead']), companionDeviceKey: serializer.fromJson(json['companionDeviceKey']), + hopCount: serializer.fromJson(json['hopCount']), + snr: serializer.fromJson(json['snr']), ); } @override @@ -1825,6 +1866,8 @@ class MessageData extends DataClass implements Insertable { 'isSentByMe': serializer.toJson(isSentByMe), 'isRead': serializer.toJson(isRead), 'companionDeviceKey': serializer.toJson(companionDeviceKey), + 'hopCount': serializer.toJson(hopCount), + 'snr': serializer.toJson(snr), }; } @@ -1842,7 +1885,9 @@ class MessageData extends DataClass implements Insertable { int? attempt, bool? isSentByMe, bool? isRead, - Value companionDeviceKey = const Value.absent()}) => + Value companionDeviceKey = const Value.absent(), + Value hopCount = const Value.absent(), + Value snr = const Value.absent()}) => MessageData( id: id ?? this.id, senderId: senderId ?? this.senderId, @@ -1860,6 +1905,8 @@ class MessageData extends DataClass implements Insertable { companionDeviceKey: companionDeviceKey.present ? companionDeviceKey.value : this.companionDeviceKey, + hopCount: hopCount.present ? hopCount.value : this.hopCount, + snr: snr.present ? snr.value : this.snr, ); MessageData copyWithCompanion(MessagesCompanion data) { return MessageData( @@ -1887,6 +1934,8 @@ class MessageData extends DataClass implements Insertable { companionDeviceKey: data.companionDeviceKey.present ? data.companionDeviceKey.value : this.companionDeviceKey, + hopCount: data.hopCount.present ? data.hopCount.value : this.hopCount, + snr: data.snr.present ? data.snr.value : this.snr, ); } @@ -1906,7 +1955,9 @@ class MessageData extends DataClass implements Insertable { ..write('attempt: $attempt, ') ..write('isSentByMe: $isSentByMe, ') ..write('isRead: $isRead, ') - ..write('companionDeviceKey: $companionDeviceKey') + ..write('companionDeviceKey: $companionDeviceKey, ') + ..write('hopCount: $hopCount, ') + ..write('snr: $snr') ..write(')')) .toString(); } @@ -1926,7 +1977,9 @@ class MessageData extends DataClass implements Insertable { attempt, isSentByMe, isRead, - companionDeviceKey); + companionDeviceKey, + hopCount, + snr); @override bool operator ==(Object other) => identical(this, other) || @@ -1944,7 +1997,9 @@ class MessageData extends DataClass implements Insertable { other.attempt == this.attempt && other.isSentByMe == this.isSentByMe && other.isRead == this.isRead && - other.companionDeviceKey == this.companionDeviceKey); + other.companionDeviceKey == this.companionDeviceKey && + other.hopCount == this.hopCount && + other.snr == this.snr); } class MessagesCompanion extends UpdateCompanion { @@ -1962,6 +2017,8 @@ class MessagesCompanion extends UpdateCompanion { final Value isSentByMe; final Value isRead; final Value companionDeviceKey; + final Value hopCount; + final Value snr; final Value rowid; const MessagesCompanion({ this.id = const Value.absent(), @@ -1978,6 +2035,8 @@ class MessagesCompanion extends UpdateCompanion { this.isSentByMe = const Value.absent(), this.isRead = const Value.absent(), this.companionDeviceKey = const Value.absent(), + this.hopCount = const Value.absent(), + this.snr = const Value.absent(), this.rowid = const Value.absent(), }); MessagesCompanion.insert({ @@ -1995,6 +2054,8 @@ class MessagesCompanion extends UpdateCompanion { required bool isSentByMe, this.isRead = const Value.absent(), this.companionDeviceKey = const Value.absent(), + this.hopCount = const Value.absent(), + this.snr = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), senderId = Value(senderId), @@ -2019,6 +2080,8 @@ class MessagesCompanion extends UpdateCompanion { Expression? isSentByMe, Expression? isRead, Expression? companionDeviceKey, + Expression? hopCount, + Expression? snr, Expression? rowid, }) { return RawValuesInsertable({ @@ -2037,6 +2100,8 @@ class MessagesCompanion extends UpdateCompanion { if (isRead != null) 'is_read': isRead, if (companionDeviceKey != null) 'companion_device_key': companionDeviceKey, + if (hopCount != null) 'hop_count': hopCount, + if (snr != null) 'snr': snr, if (rowid != null) 'rowid': rowid, }); } @@ -2056,6 +2121,8 @@ class MessagesCompanion extends UpdateCompanion { Value? isSentByMe, Value? isRead, Value? companionDeviceKey, + Value? hopCount, + Value? snr, Value? rowid}) { return MessagesCompanion( id: id ?? this.id, @@ -2072,6 +2139,8 @@ class MessagesCompanion extends UpdateCompanion { isSentByMe: isSentByMe ?? this.isSentByMe, isRead: isRead ?? this.isRead, companionDeviceKey: companionDeviceKey ?? this.companionDeviceKey, + hopCount: hopCount ?? this.hopCount, + snr: snr ?? this.snr, rowid: rowid ?? this.rowid, ); } @@ -2121,6 +2190,12 @@ class MessagesCompanion extends UpdateCompanion { if (companionDeviceKey.present) { map['companion_device_key'] = Variable(companionDeviceKey.value); } + if (hopCount.present) { + map['hop_count'] = Variable(hopCount.value); + } + if (snr.present) { + map['snr'] = Variable(snr.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -2144,12 +2219,391 @@ class MessagesCompanion extends UpdateCompanion { ..write('isSentByMe: $isSentByMe, ') ..write('isRead: $isRead, ') ..write('companionDeviceKey: $companionDeviceKey, ') + ..write('hopCount: $hopCount, ') + ..write('snr: $snr, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } +class $MessagePathsTable extends MessagePaths + with TableInfo<$MessagePathsTable, MessagePathData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $MessagePathsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _messageIdMeta = + const VerificationMeta('messageId'); + @override + late final GeneratedColumn messageId = GeneratedColumn( + 'message_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _pathByteMeta = + const VerificationMeta('pathByte'); + @override + late final GeneratedColumn pathByte = GeneratedColumn( + 'path_byte', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const VerificationMeta _pathBytesMeta = + const VerificationMeta('pathBytes'); + @override + late final GeneratedColumn pathBytes = GeneratedColumn( + 'path_bytes', aliasedName, false, + type: DriftSqlType.blob, requiredDuringInsert: true); + static const VerificationMeta _snrMeta = const VerificationMeta('snr'); + @override + late final GeneratedColumn snr = GeneratedColumn( + 'snr', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _rssiMeta = const VerificationMeta('rssi'); + @override + late final GeneratedColumn rssi = GeneratedColumn( + 'rssi', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _receivedAtMeta = + const VerificationMeta('receivedAt'); + @override + late final GeneratedColumn receivedAt = GeneratedColumn( + 'received_at', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => + [id, messageId, pathByte, pathBytes, snr, rssi, receivedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'message_paths'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('message_id')) { + context.handle(_messageIdMeta, + messageId.isAcceptableOrUnknown(data['message_id']!, _messageIdMeta)); + } else if (isInserting) { + context.missing(_messageIdMeta); + } + if (data.containsKey('path_byte')) { + context.handle(_pathByteMeta, + pathByte.isAcceptableOrUnknown(data['path_byte']!, _pathByteMeta)); + } else if (isInserting) { + context.missing(_pathByteMeta); + } + if (data.containsKey('path_bytes')) { + context.handle(_pathBytesMeta, + pathBytes.isAcceptableOrUnknown(data['path_bytes']!, _pathBytesMeta)); + } else if (isInserting) { + context.missing(_pathBytesMeta); + } + if (data.containsKey('snr')) { + context.handle( + _snrMeta, snr.isAcceptableOrUnknown(data['snr']!, _snrMeta)); + } + if (data.containsKey('rssi')) { + context.handle( + _rssiMeta, rssi.isAcceptableOrUnknown(data['rssi']!, _rssiMeta)); + } + if (data.containsKey('received_at')) { + context.handle( + _receivedAtMeta, + receivedAt.isAcceptableOrUnknown( + data['received_at']!, _receivedAtMeta)); + } else if (isInserting) { + context.missing(_receivedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {messageId, pathBytes}, + ]; + @override + MessagePathData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MessagePathData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + messageId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}message_id'])!, + pathByte: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}path_byte'])!, + pathBytes: attachedDatabase.typeMapping + .read(DriftSqlType.blob, data['${effectivePrefix}path_bytes'])!, + snr: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}snr']), + rssi: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}rssi']), + receivedAt: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}received_at'])!, + ); + } + + @override + $MessagePathsTable createAlias(String alias) { + return $MessagePathsTable(attachedDatabase, alias); + } +} + +class MessagePathData extends DataClass implements Insertable { + final int id; + final String messageId; + final int pathByte; + final Uint8List pathBytes; + final int? snr; + final int? rssi; + final int receivedAt; + const MessagePathData( + {required this.id, + required this.messageId, + required this.pathByte, + required this.pathBytes, + this.snr, + this.rssi, + required this.receivedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['message_id'] = Variable(messageId); + map['path_byte'] = Variable(pathByte); + map['path_bytes'] = Variable(pathBytes); + if (!nullToAbsent || snr != null) { + map['snr'] = Variable(snr); + } + if (!nullToAbsent || rssi != null) { + map['rssi'] = Variable(rssi); + } + map['received_at'] = Variable(receivedAt); + return map; + } + + MessagePathsCompanion toCompanion(bool nullToAbsent) { + return MessagePathsCompanion( + id: Value(id), + messageId: Value(messageId), + pathByte: Value(pathByte), + pathBytes: Value(pathBytes), + snr: snr == null && nullToAbsent ? const Value.absent() : Value(snr), + rssi: rssi == null && nullToAbsent ? const Value.absent() : Value(rssi), + receivedAt: Value(receivedAt), + ); + } + + factory MessagePathData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MessagePathData( + id: serializer.fromJson(json['id']), + messageId: serializer.fromJson(json['messageId']), + pathByte: serializer.fromJson(json['pathByte']), + pathBytes: serializer.fromJson(json['pathBytes']), + snr: serializer.fromJson(json['snr']), + rssi: serializer.fromJson(json['rssi']), + receivedAt: serializer.fromJson(json['receivedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'messageId': serializer.toJson(messageId), + 'pathByte': serializer.toJson(pathByte), + 'pathBytes': serializer.toJson(pathBytes), + 'snr': serializer.toJson(snr), + 'rssi': serializer.toJson(rssi), + 'receivedAt': serializer.toJson(receivedAt), + }; + } + + MessagePathData copyWith( + {int? id, + String? messageId, + int? pathByte, + Uint8List? pathBytes, + Value snr = const Value.absent(), + Value rssi = const Value.absent(), + int? receivedAt}) => + MessagePathData( + id: id ?? this.id, + messageId: messageId ?? this.messageId, + pathByte: pathByte ?? this.pathByte, + pathBytes: pathBytes ?? this.pathBytes, + snr: snr.present ? snr.value : this.snr, + rssi: rssi.present ? rssi.value : this.rssi, + receivedAt: receivedAt ?? this.receivedAt, + ); + MessagePathData copyWithCompanion(MessagePathsCompanion data) { + return MessagePathData( + id: data.id.present ? data.id.value : this.id, + messageId: data.messageId.present ? data.messageId.value : this.messageId, + pathByte: data.pathByte.present ? data.pathByte.value : this.pathByte, + pathBytes: data.pathBytes.present ? data.pathBytes.value : this.pathBytes, + snr: data.snr.present ? data.snr.value : this.snr, + rssi: data.rssi.present ? data.rssi.value : this.rssi, + receivedAt: + data.receivedAt.present ? data.receivedAt.value : this.receivedAt, + ); + } + + @override + String toString() { + return (StringBuffer('MessagePathData(') + ..write('id: $id, ') + ..write('messageId: $messageId, ') + ..write('pathByte: $pathByte, ') + ..write('pathBytes: $pathBytes, ') + ..write('snr: $snr, ') + ..write('rssi: $rssi, ') + ..write('receivedAt: $receivedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, messageId, pathByte, + $driftBlobEquality.hash(pathBytes), snr, rssi, receivedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MessagePathData && + other.id == this.id && + other.messageId == this.messageId && + other.pathByte == this.pathByte && + $driftBlobEquality.equals(other.pathBytes, this.pathBytes) && + other.snr == this.snr && + other.rssi == this.rssi && + other.receivedAt == this.receivedAt); +} + +class MessagePathsCompanion extends UpdateCompanion { + final Value id; + final Value messageId; + final Value pathByte; + final Value pathBytes; + final Value snr; + final Value rssi; + final Value receivedAt; + const MessagePathsCompanion({ + this.id = const Value.absent(), + this.messageId = const Value.absent(), + this.pathByte = const Value.absent(), + this.pathBytes = const Value.absent(), + this.snr = const Value.absent(), + this.rssi = const Value.absent(), + this.receivedAt = const Value.absent(), + }); + MessagePathsCompanion.insert({ + this.id = const Value.absent(), + required String messageId, + required int pathByte, + required Uint8List pathBytes, + this.snr = const Value.absent(), + this.rssi = const Value.absent(), + required int receivedAt, + }) : messageId = Value(messageId), + pathByte = Value(pathByte), + pathBytes = Value(pathBytes), + receivedAt = Value(receivedAt); + static Insertable custom({ + Expression? id, + Expression? messageId, + Expression? pathByte, + Expression? pathBytes, + Expression? snr, + Expression? rssi, + Expression? receivedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (messageId != null) 'message_id': messageId, + if (pathByte != null) 'path_byte': pathByte, + if (pathBytes != null) 'path_bytes': pathBytes, + if (snr != null) 'snr': snr, + if (rssi != null) 'rssi': rssi, + if (receivedAt != null) 'received_at': receivedAt, + }); + } + + MessagePathsCompanion copyWith( + {Value? id, + Value? messageId, + Value? pathByte, + Value? pathBytes, + Value? snr, + Value? rssi, + Value? receivedAt}) { + return MessagePathsCompanion( + id: id ?? this.id, + messageId: messageId ?? this.messageId, + pathByte: pathByte ?? this.pathByte, + pathBytes: pathBytes ?? this.pathBytes, + snr: snr ?? this.snr, + rssi: rssi ?? this.rssi, + receivedAt: receivedAt ?? this.receivedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (messageId.present) { + map['message_id'] = Variable(messageId.value); + } + if (pathByte.present) { + map['path_byte'] = Variable(pathByte.value); + } + if (pathBytes.present) { + map['path_bytes'] = Variable(pathBytes.value); + } + if (snr.present) { + map['snr'] = Variable(snr.value); + } + if (rssi.present) { + map['rssi'] = Variable(rssi.value); + } + if (receivedAt.present) { + map['received_at'] = Variable(receivedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MessagePathsCompanion(') + ..write('id: $id, ') + ..write('messageId: $messageId, ') + ..write('pathByte: $pathByte, ') + ..write('pathBytes: $pathBytes, ') + ..write('snr: $snr, ') + ..write('rssi: $rssi, ') + ..write('receivedAt: $receivedAt') + ..write(')')) + .toString(); + } +} + class $WaypointsTable extends Waypoints with TableInfo<$WaypointsTable, WaypointData> { @override @@ -5863,6 +6317,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $ContactsTable contacts = $ContactsTable(this); late final $ChannelsTable channels = $ChannelsTable(this); late final $MessagesTable messages = $MessagesTable(this); + late final $MessagePathsTable messagePaths = $MessagePathsTable(this); late final $WaypointsTable waypoints = $WaypointsTable(this); late final $CompanionDevicesTable companionDevices = $CompanionDevicesTable(this); @@ -5878,6 +6333,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final ContactsDao contactsDao = ContactsDao(this as AppDatabase); late final ChannelsDao channelsDao = ChannelsDao(this as AppDatabase); late final MessagesDao messagesDao = MessagesDao(this as AppDatabase); + late final MessagePathsDao messagePathsDao = + MessagePathsDao(this as AppDatabase); late final WaypointsDao waypointsDao = WaypointsDao(this as AppDatabase); late final AckRecordsDao ackRecordsDao = AckRecordsDao(this as AppDatabase); late final CompanionDevicesDao companionDevicesDao = @@ -5894,6 +6351,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { contacts, channels, messages, + messagePaths, waypoints, companionDevices, contactDisplayStates, @@ -6533,6 +6991,8 @@ typedef $$MessagesTableCreateCompanionBuilder = MessagesCompanion Function({ required bool isSentByMe, Value isRead, Value companionDeviceKey, + Value hopCount, + Value snr, Value rowid, }); typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ @@ -6550,6 +7010,8 @@ typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ Value isSentByMe, Value isRead, Value companionDeviceKey, + Value hopCount, + Value snr, Value rowid, }); @@ -6605,6 +7067,12 @@ class $$MessagesTableFilterComposer ColumnFilters get companionDeviceKey => $composableBuilder( column: $table.companionDeviceKey, builder: (column) => ColumnFilters(column)); + + ColumnFilters get hopCount => $composableBuilder( + column: $table.hopCount, builder: (column) => ColumnFilters(column)); + + ColumnFilters get snr => $composableBuilder( + column: $table.snr, builder: (column) => ColumnFilters(column)); } class $$MessagesTableOrderingComposer @@ -6660,6 +7128,12 @@ class $$MessagesTableOrderingComposer ColumnOrderings get companionDeviceKey => $composableBuilder( column: $table.companionDeviceKey, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get hopCount => $composableBuilder( + column: $table.hopCount, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get snr => $composableBuilder( + column: $table.snr, builder: (column) => ColumnOrderings(column)); } class $$MessagesTableAnnotationComposer @@ -6712,6 +7186,12 @@ class $$MessagesTableAnnotationComposer GeneratedColumn get companionDeviceKey => $composableBuilder( column: $table.companionDeviceKey, builder: (column) => column); + + GeneratedColumn get hopCount => + $composableBuilder(column: $table.hopCount, builder: (column) => column); + + GeneratedColumn get snr => + $composableBuilder(column: $table.snr, builder: (column) => column); } class $$MessagesTableTableManager extends RootTableManager< @@ -6751,6 +7231,8 @@ class $$MessagesTableTableManager extends RootTableManager< Value isSentByMe = const Value.absent(), Value isRead = const Value.absent(), Value companionDeviceKey = const Value.absent(), + Value hopCount = const Value.absent(), + Value snr = const Value.absent(), Value rowid = const Value.absent(), }) => MessagesCompanion( @@ -6768,6 +7250,8 @@ class $$MessagesTableTableManager extends RootTableManager< isSentByMe: isSentByMe, isRead: isRead, companionDeviceKey: companionDeviceKey, + hopCount: hopCount, + snr: snr, rowid: rowid, ), createCompanionCallback: ({ @@ -6785,6 +7269,8 @@ class $$MessagesTableTableManager extends RootTableManager< required bool isSentByMe, Value isRead = const Value.absent(), Value companionDeviceKey = const Value.absent(), + Value hopCount = const Value.absent(), + Value snr = const Value.absent(), Value rowid = const Value.absent(), }) => MessagesCompanion.insert( @@ -6802,6 +7288,8 @@ class $$MessagesTableTableManager extends RootTableManager< isSentByMe: isSentByMe, isRead: isRead, companionDeviceKey: companionDeviceKey, + hopCount: hopCount, + snr: snr, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -6823,6 +7311,203 @@ typedef $$MessagesTableProcessedTableManager = ProcessedTableManager< (MessageData, BaseReferences<_$AppDatabase, $MessagesTable, MessageData>), MessageData, PrefetchHooks Function()>; +typedef $$MessagePathsTableCreateCompanionBuilder = MessagePathsCompanion + Function({ + Value id, + required String messageId, + required int pathByte, + required Uint8List pathBytes, + Value snr, + Value rssi, + required int receivedAt, +}); +typedef $$MessagePathsTableUpdateCompanionBuilder = MessagePathsCompanion + Function({ + Value id, + Value messageId, + Value pathByte, + Value pathBytes, + Value snr, + Value rssi, + Value receivedAt, +}); + +class $$MessagePathsTableFilterComposer + extends Composer<_$AppDatabase, $MessagePathsTable> { + $$MessagePathsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get messageId => $composableBuilder( + column: $table.messageId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get pathByte => $composableBuilder( + column: $table.pathByte, builder: (column) => ColumnFilters(column)); + + ColumnFilters get pathBytes => $composableBuilder( + column: $table.pathBytes, builder: (column) => ColumnFilters(column)); + + ColumnFilters get snr => $composableBuilder( + column: $table.snr, builder: (column) => ColumnFilters(column)); + + ColumnFilters get rssi => $composableBuilder( + column: $table.rssi, builder: (column) => ColumnFilters(column)); + + ColumnFilters get receivedAt => $composableBuilder( + column: $table.receivedAt, builder: (column) => ColumnFilters(column)); +} + +class $$MessagePathsTableOrderingComposer + extends Composer<_$AppDatabase, $MessagePathsTable> { + $$MessagePathsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get messageId => $composableBuilder( + column: $table.messageId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get pathByte => $composableBuilder( + column: $table.pathByte, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get pathBytes => $composableBuilder( + column: $table.pathBytes, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get snr => $composableBuilder( + column: $table.snr, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get rssi => $composableBuilder( + column: $table.rssi, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get receivedAt => $composableBuilder( + column: $table.receivedAt, builder: (column) => ColumnOrderings(column)); +} + +class $$MessagePathsTableAnnotationComposer + extends Composer<_$AppDatabase, $MessagePathsTable> { + $$MessagePathsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get messageId => + $composableBuilder(column: $table.messageId, builder: (column) => column); + + GeneratedColumn get pathByte => + $composableBuilder(column: $table.pathByte, builder: (column) => column); + + GeneratedColumn get pathBytes => + $composableBuilder(column: $table.pathBytes, builder: (column) => column); + + GeneratedColumn get snr => + $composableBuilder(column: $table.snr, builder: (column) => column); + + GeneratedColumn get rssi => + $composableBuilder(column: $table.rssi, builder: (column) => column); + + GeneratedColumn get receivedAt => $composableBuilder( + column: $table.receivedAt, builder: (column) => column); +} + +class $$MessagePathsTableTableManager extends RootTableManager< + _$AppDatabase, + $MessagePathsTable, + MessagePathData, + $$MessagePathsTableFilterComposer, + $$MessagePathsTableOrderingComposer, + $$MessagePathsTableAnnotationComposer, + $$MessagePathsTableCreateCompanionBuilder, + $$MessagePathsTableUpdateCompanionBuilder, + ( + MessagePathData, + BaseReferences<_$AppDatabase, $MessagePathsTable, MessagePathData> + ), + MessagePathData, + PrefetchHooks Function()> { + $$MessagePathsTableTableManager(_$AppDatabase db, $MessagePathsTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$MessagePathsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$MessagePathsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$MessagePathsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value messageId = const Value.absent(), + Value pathByte = const Value.absent(), + Value pathBytes = const Value.absent(), + Value snr = const Value.absent(), + Value rssi = const Value.absent(), + Value receivedAt = const Value.absent(), + }) => + MessagePathsCompanion( + id: id, + messageId: messageId, + pathByte: pathByte, + pathBytes: pathBytes, + snr: snr, + rssi: rssi, + receivedAt: receivedAt, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required String messageId, + required int pathByte, + required Uint8List pathBytes, + Value snr = const Value.absent(), + Value rssi = const Value.absent(), + required int receivedAt, + }) => + MessagePathsCompanion.insert( + id: id, + messageId: messageId, + pathByte: pathByte, + pathBytes: pathBytes, + snr: snr, + rssi: rssi, + receivedAt: receivedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$MessagePathsTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $MessagePathsTable, + MessagePathData, + $$MessagePathsTableFilterComposer, + $$MessagePathsTableOrderingComposer, + $$MessagePathsTableAnnotationComposer, + $$MessagePathsTableCreateCompanionBuilder, + $$MessagePathsTableUpdateCompanionBuilder, + ( + MessagePathData, + BaseReferences<_$AppDatabase, $MessagePathsTable, MessagePathData> + ), + MessagePathData, + PrefetchHooks Function()>; typedef $$WaypointsTableCreateCompanionBuilder = WaypointsCompanion Function({ required String id, Value meshId, @@ -8611,6 +9296,8 @@ class $AppDatabaseManager { $$ChannelsTableTableManager(_db, _db.channels); $$MessagesTableTableManager get messages => $$MessagesTableTableManager(_db, _db.messages); + $$MessagePathsTableTableManager get messagePaths => + $$MessagePathsTableTableManager(_db, _db.messagePaths); $$WaypointsTableTableManager get waypoints => $$WaypointsTableTableManager(_db, _db.waypoints); $$CompanionDevicesTableTableManager get companionDevices => diff --git a/lib/database/tables.dart b/lib/database/tables.dart index 9dcf2c6..46f24c9 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -37,8 +37,7 @@ class Contacts extends Table { false))(); // Remote device is in autonomous mode (no phone attached) TextColumn get companionDeviceKey => text() .nullable()(); // Which companion this contact belongs to (hex string) - BoolColumn get isFavorite => - boolean().withDefault(const Constant(false))(); + BoolColumn get isFavorite => boolean().withDefault(const Constant(false))(); @override Set get primaryKey => {publicKey}; @@ -59,8 +58,7 @@ class Channels extends Table { IntColumn get createdAt => integer()(); // Unix timestamp TextColumn get notificationMode => text().withDefault(const Constant('normal'))(); - BoolColumn get isFavorite => - boolean().withDefault(const Constant(false))(); + BoolColumn get isFavorite => boolean().withDefault(const Constant(false))(); TextColumn get companionDeviceKey => text().nullable()(); // Which companion this channel belongs to @@ -91,11 +89,44 @@ class Messages extends Table { BoolColumn get isRead => boolean().withDefault(const Constant(false))(); // Message read status TextColumn get companionDeviceKey => text().nullable()(); + IntColumn get hopCount => integer() + .nullable()(); // Relay hops for this specific message (null = unknown, 0 = direct) + IntColumn get snr => + integer().nullable()(); // Signal-to-noise ratio for this message @override Set get primaryKey => {id}; } +/// Message paths table - one row per distinct radio path a message was +/// observed on (direct, or via one or more relays), correlated from raw +/// PUSH_LOG_RX_DATA frames. A message with no rows here just falls back to +/// Messages.hopCount/snr (the single-path summary firmware already gives us). +/// Stored raw/undecoded on purpose -- this is on-demand display data, not +/// queried in bulk, so decoding pathByte/pathBytes into hop count, hash +/// size, and per-hop identifiers happens lazily in the UI layer. +@DataClassName('MessagePathData') +class MessagePaths extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get messageId => text()(); // References Messages.id + IntColumn get pathByte => + integer()(); // Raw wire path_length byte (hash-size mode + hop count packed) + BlobColumn get pathBytes => + blob()(); // Raw hop-hash bytes, hop_count * hash_size long (empty = direct) + IntColumn get snr => integer().nullable()(); // From this specific raw frame + IntColumn get rssi => integer().nullable()(); // From this specific raw frame + IntColumn get receivedAt => integer()(); // Unix timestamp ms of the raw frame + + // Correlation can run concurrently for the same message (delivered via + // PUSH and then again via a later sync -- an existing pattern elsewhere + // in this app). This constraint makes de-duplication atomic at the DB + // layer instead of racy application-side check-then-insert. + @override + List> get uniqueKeys => [ + {messageId, pathBytes}, + ]; +} + /// Waypoints table - stores GPS waypoints for map markers /// Matches Android Waypoint entity @DataClassName('WaypointData') diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9393f36..beb4ae1 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -151,6 +151,53 @@ "directMessagesDisabledForRepeaters": "Direktnachrichten sind fΓΌr Repeater deaktiviert", "typeAMessage": "Nachricht eingeben...", "copyMessageText": "Nachrichtentext kopieren", + "messagePath": "Nachrichtenpfad", + "hopDirect": "Direkt", + "hopsCount": "{count} {count, plural, one{Hop} other{Hops}}", + "@hopsCount": { + "placeholders": { + "count": { "type": "int" } + } + }, + "pathNumber": "Pfad {number}", + "@pathNumber": { + "placeholders": { + "number": { "type": "int" } + } + }, + "snrValue": "{value} dB SNR", + "@snrValue": { + "placeholders": { + "value": { "type": "String" } + } + }, + "rssiValue": "{value} dBm RSSI", + "@rssiValue": { + "placeholders": { + "value": { "type": "int" } + } + }, + "hopLabel": "Hop {number}: {name}", + "@hopLabel": { + "placeholders": { + "number": { "type": "int" }, + "name": { "type": "String" } + } + }, + "hopLabelWithSignal": "Hop {number}: {name} β€” {signal}", + "@hopLabelWithSignal": { + "placeholders": { + "number": { "type": "int" }, + "name": { "type": "String" }, + "signal": { "type": "String" } + } + }, + "ambiguousHop": "{names} (mehrdeutig)", + "@ambiguousHop": { + "placeholders": { + "names": { "type": "String" } + } + }, "notificationsMuted": "πŸ”• Stumm", "notificationsSilent": "πŸ”• Lautlos", "channelNotifications": "Kanal-Benachrichtigungen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a6a6e11..40e0864 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -151,6 +151,53 @@ "directMessagesDisabledForRepeaters": "Direct messages are disabled for repeaters", "typeAMessage": "Type a message...", "copyMessageText": "Copy message text", + "messagePath": "Message path", + "hopDirect": "Direct", + "hopsCount": "{count} {count, plural, one{hop} other{hops}}", + "@hopsCount": { + "placeholders": { + "count": { "type": "int" } + } + }, + "pathNumber": "Path {number}", + "@pathNumber": { + "placeholders": { + "number": { "type": "int" } + } + }, + "snrValue": "{value} dB SNR", + "@snrValue": { + "placeholders": { + "value": { "type": "String" } + } + }, + "rssiValue": "{value} dBm RSSI", + "@rssiValue": { + "placeholders": { + "value": { "type": "int" } + } + }, + "hopLabel": "Hop {number}: {name}", + "@hopLabel": { + "placeholders": { + "number": { "type": "int" }, + "name": { "type": "String" } + } + }, + "hopLabelWithSignal": "Hop {number}: {name} β€” {signal}", + "@hopLabelWithSignal": { + "placeholders": { + "number": { "type": "int" }, + "name": { "type": "String" }, + "signal": { "type": "String" } + } + }, + "ambiguousHop": "{names} (ambiguous)", + "@ambiguousHop": { + "placeholders": { + "names": { "type": "String" } + } + }, "notificationsMuted": "πŸ”• Muted", "notificationsSilent": "πŸ”• Silent", "channelNotifications": "Channel notifications", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 855eec9..b428355 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -884,6 +884,60 @@ abstract class AppLocalizations { /// **'Copy message text'** String get copyMessageText; + /// No description provided for @messagePath. + /// + /// In en, this message translates to: + /// **'Message path'** + String get messagePath; + + /// No description provided for @hopDirect. + /// + /// In en, this message translates to: + /// **'Direct'** + String get hopDirect; + + /// No description provided for @hopsCount. + /// + /// In en, this message translates to: + /// **'{count} {count, plural, one{hop} other{hops}}'** + String hopsCount(int count); + + /// No description provided for @pathNumber. + /// + /// In en, this message translates to: + /// **'Path {number}'** + String pathNumber(int number); + + /// No description provided for @snrValue. + /// + /// In en, this message translates to: + /// **'{value} dB SNR'** + String snrValue(String value); + + /// No description provided for @rssiValue. + /// + /// In en, this message translates to: + /// **'{value} dBm RSSI'** + String rssiValue(int value); + + /// No description provided for @hopLabel. + /// + /// In en, this message translates to: + /// **'Hop {number}: {name}'** + String hopLabel(int number, String name); + + /// No description provided for @hopLabelWithSignal. + /// + /// In en, this message translates to: + /// **'Hop {number}: {name} β€” {signal}'** + String hopLabelWithSignal(int number, String name, String signal); + + /// No description provided for @ambiguousHop. + /// + /// In en, this message translates to: + /// **'{names} (ambiguous)'** + String ambiguousHop(String names); + /// No description provided for @notificationsMuted. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ed82870..a77896a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -412,6 +412,53 @@ class AppLocalizationsDe extends AppLocalizations { @override String get copyMessageText => 'Nachrichtentext kopieren'; + @override + String get messagePath => 'Nachrichtenpfad'; + + @override + String get hopDirect => 'Direkt'; + + @override + String hopsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Hops', + one: 'Hop', + ); + return '$count $_temp0'; + } + + @override + String pathNumber(int number) { + return 'Pfad $number'; + } + + @override + String snrValue(String value) { + return '$value dB SNR'; + } + + @override + String rssiValue(int value) { + return '$value dBm RSSI'; + } + + @override + String hopLabel(int number, String name) { + return 'Hop $number: $name'; + } + + @override + String hopLabelWithSignal(int number, String name, String signal) { + return 'Hop $number: $name β€” $signal'; + } + + @override + String ambiguousHop(String names) { + return '$names (mehrdeutig)'; + } + @override String get notificationsMuted => 'πŸ”• Stumm'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index efd3b48..f88d7a6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -408,6 +408,53 @@ class AppLocalizationsEn extends AppLocalizations { @override String get copyMessageText => 'Copy message text'; + @override + String get messagePath => 'Message path'; + + @override + String get hopDirect => 'Direct'; + + @override + String hopsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'hops', + one: 'hop', + ); + return '$count $_temp0'; + } + + @override + String pathNumber(int number) { + return 'Path $number'; + } + + @override + String snrValue(String value) { + return '$value dB SNR'; + } + + @override + String rssiValue(int value) { + return '$value dBm RSSI'; + } + + @override + String hopLabel(int number, String name) { + return 'Hop $number: $name'; + } + + @override + String hopLabelWithSignal(int number, String name, String signal) { + return 'Hop $number: $name β€” $signal'; + } + + @override + String ambiguousHop(String names) { + return '$names (ambiguous)'; + } + @override String get notificationsMuted => 'πŸ”• Muted'; diff --git a/lib/repositories/contact_repository.dart b/lib/repositories/contact_repository.dart index 9bf2317..fcdb671 100644 --- a/lib/repositories/contact_repository.dart +++ b/lib/repositories/contact_repository.dart @@ -434,7 +434,8 @@ class ContactRepository { if (companionKey != null && companionKey.isNotEmpty) { final query = _contactsDao.selectOnly(_contactsDao.contacts) ..addColumns([_contactsDao.contacts.hash.count()]) - ..where(_contactsDao.contacts.companionDeviceKey.equals(companionKey)); + ..where( + _contactsDao.contacts.companionDeviceKey.equals(companionKey)); return query .watchSingle() .map((row) => row.read(_contactsDao.contacts.hash.count()) ?? 0) @@ -464,6 +465,21 @@ class ContactRepository { return _contactsDao.setFavorite(publicKey, isFavorite); } + /// All contacts matching a short public-key prefix (e.g. a raw radio path + /// hop hash, 1-3 bytes). Unlike a single-match lookup, this surfaces + /// ambiguity when a short prefix collides between multiple contacts. + Future> getContactsByPublicKeyPrefix( + Uint8List prefix, { + int prefixLength = 6, + String? companionKey, + }) { + return _contactsDao.getContactsByPublicKeyPrefix( + prefix, + prefixLength: prefixLength, + companionKey: companionKey, + ); + } + /// Delete a single contact from the local DB and from the companion (if connected). /// A "not found" response from the companion is not an error. Future deleteContact(ContactData contact) async { @@ -481,19 +497,18 @@ class ContactRepository { .millisecondsSinceEpoch; final nowMs = DateTime.now().millisecondsSinceEpoch; - final stale = (await _contactsDao.getAllContacts()) - .where((c) { - // Clamp future timestamps to now (companion clock may be ahead). - final effectiveLastSeen = c.lastSeen > nowMs ? nowMs : c.lastSeen; - return effectiveLastSeen < cutoffMs && !c.isFavorite; - }) - .toList(); + final stale = (await _contactsDao.getAllContacts()).where((c) { + // Clamp future timestamps to now (companion clock may be ahead). + final effectiveLastSeen = c.lastSeen > nowMs ? nowMs : c.lastSeen; + return effectiveLastSeen < cutoffMs && !c.isFavorite; + }).toList(); for (final contact in stale) { await deleteContact(contact); } - debugPrint('[ContactPurge] Removed ${stale.length} contacts older than $days days'); + debugPrint( + '[ContactPurge] Removed ${stale.length} contacts older than $days days'); return stale.length; } diff --git a/lib/repositories/message_repository.dart b/lib/repositories/message_repository.dart index e7a238c..fd44b31 100644 --- a/lib/repositories/message_repository.dart +++ b/lib/repositories/message_repository.dart @@ -14,6 +14,8 @@ import 'package:meshcore_team/ble/ble_connection_manager.dart'; import 'package:meshcore_team/ble/ble_constants.dart'; import 'package:meshcore_team/ble/ble_responses.dart'; import 'package:meshcore_team/ble/ble_service.dart'; +import 'package:meshcore_team/ble/raw_packet_log.dart'; +import 'package:meshcore_team/services/channel_crypto.dart'; import 'package:meshcore_team/database/database.dart'; import 'package:meshcore_team/database/daos/messages_dao.dart'; import 'package:meshcore_team/database/daos/channels_dao.dart'; @@ -93,6 +95,15 @@ class MessageRepository { static const Duration _recentTelemetryTtl = Duration(seconds: 5); final Map _recentTelemetryKeysMs = {}; + // Recent raw radio packets, used to correlate real per-hop path data with + // decoded channel messages (see _correlateChannelMessagePaths). + final RawPacketLog _rawPacketLog = RawPacketLog(); + + // Recently-decoded channel messages, kept briefly so a multi-path sibling + // raw frame that arrives *after* a message was already processed can + // still be matched against it (see _tryCorrelateNewFrame). + final List<_RecentChannelMessage> _recentChannelMessages = []; + // Broadcast stream of parsed #TEL events. final StreamController _telemetryStreamController = StreamController.broadcast(); @@ -139,6 +150,13 @@ class MessageRepository { // TODO: Remove this after migrating all screens to use repository methods MessagesDao get messagesDao => _messagesDao; + /// All observed radio paths for a message (see MessagePaths table doc), + /// oldest first. Empty if correlation never matched a raw frame to this + /// message -- callers should fall back to Messages.hopCount/snr. + Future> getMessagePaths(String messageId) { + return _database.messagePathsDao.getPathsByMessage(messageId); + } + /// Watch messages for a channel, automatically filtered by current companion /// Auto-switches when currentCompanionPublicKey changes /// Matches Android MessageRepository.getMessagesByChannel() @@ -208,6 +226,16 @@ class MessageRepository { if (responseCode == BleConstants.pushCodeLogRxData) { debugPrint( '[MessageSync] πŸ“‘ PUSH_LOG_RX_DATA received - triggering message sync'); + // Buffer the raw packet too -- used to correlate real per-hop path + // data with the decoded message this push precedes (see + // _correlateChannelMessagePaths). Parsing failure here is silent by + // design: the raw frame is a best-effort enhancement layered on top + // of the sync flow below, never a dependency of it. + final rawFrame = parseRawPacketLogFrame(frame); + if (rawFrame != null) { + _rawPacketLog.add(rawFrame); + unawaited(_tryCorrelateNewFrame(rawFrame)); + } // Small delay to let firmware queue the message _requestMessageSync( delay: const Duration(milliseconds: 100), @@ -666,6 +694,8 @@ class MessageRepository { deliveryStatus: 'DELIVERED', companionDeviceKey: drift.Value(_settingsService.settings.currentCompanionPublicKey), + hopCount: drift.Value(response.pathLength), + snr: drift.Value(response.snr), ); // Insert message (async, fire-and-forget) @@ -831,6 +861,8 @@ class MessageRepository { deliveryStatus: 'RECEIVED', companionDeviceKey: drift.Value(_settingsService.settings.currentCompanionPublicKey), + hopCount: drift.Value(response.pathLength), + snr: drift.Value(response.snr), ); // Insert message @@ -853,20 +885,178 @@ class MessageRepository { '[MessageSync] πŸ”” Notification shown for channel message in ${channel.name}'); } } catch (e) { - // Silently ignore duplicate key errors (message already saved) + // Silently ignore duplicate key errors (message already saved) -- + // but still attempt path correlation below, since a "duplicate" + // here means this is a second (or Nth) physical reception of the + // same logical message via a different radio path, which is + // exactly the case we want to capture, not discard. if (!e.toString().contains('UNIQUE constraint')) { rethrow; } else { debugPrint( '[MessageSync] πŸ”„ Duplicate channel message ignored (already saved)'); - return; } } + + unawaited(_correlateChannelMessagePaths( + messageId: messageId, + channelSecret: channel.sharedKey, + response: response, + )); } catch (e) { debugPrint('[MessageSync] ⚠️ Error handling channel message: $e'); } } + /// Correlate a decoded channel message with the raw radio packet(s) it was + /// physically received on, to capture real per-hop path data (RSSI, and + /// which repeaters relayed it) that firmware's decoded message response + /// never exposes -- only a bare hop count. + /// + /// We already know this message's exact plaintext and its channel's PSK, + /// so instead of decrypting raw frames (which would require reimplementing + /// MeshCore's full crypto/channel-matching), we re-encrypt the known + /// plaintext and look for an exact ciphertext match among recently + /// buffered raw frames. An exact multi-byte match is unambiguous -- no + /// heuristics, no guessing. The over-the-air plaintext packs a 2-bit + /// `attempt` retry counter that firmware's V3 response doesn't expose to + /// us, so all 4 possible values are tried. + /// + /// Once one raw frame matches, every other buffered frame sharing its + /// packetHash (payload_type + ciphertext, independent of path) is a + /// multi-path sibling -- the same logical packet, heard via another route. + /// Best-effort only: if no raw frame matches (aged out of the buffer, or + /// this is a message type we don't attempt here), this is a no-op and the + /// existing Messages.hopCount/snr summary remains the sole source of + /// truth for that message, unaffected. + Future _correlateChannelMessagePaths({ + required String messageId, + required Uint8List channelSecret, + required ChannelMessageReceivedResponse response, + }) async { + // Flood-relayed siblings of the same packet can arrive as separate raw + // frames several hundred ms to seconds apart (direct reception, then a + // repeater's relay) -- often *after* this message has already been + // decoded and correlated once. Remember it so a later-arriving frame + // (see _tryCorrelateNewFrame) can still be matched against it, not just + // frames already buffered right now. + _recentChannelMessages.add(_RecentChannelMessage( + messageId: messageId, + channelSecret: channelSecret, + timestamp: response.timestamp, + txtType: response.txtType, + text: response.text, + insertedAtMs: DateTime.now().millisecondsSinceEpoch, + )); + _pruneRecentChannelMessages(); + + await _tryCorrelate( + messageId: messageId, + channelSecret: channelSecret, + timestamp: response.timestamp, + txtType: response.txtType, + text: response.text, + candidates: _rawPacketLog.byPayloadType(payloadTypeGrpTxt), + ); + } + + /// Re-checks a newly-arrived raw frame against recently-decoded channel + /// messages, for the case where a multi-path sibling frame arrives after + /// its message was already processed (see _correlateChannelMessagePaths). + Future _tryCorrelateNewFrame(RawPacketFrame frame) async { + if (frame.payloadType != payloadTypeGrpTxt) return; + _pruneRecentChannelMessages(); + for (final recent in _recentChannelMessages) { + await _tryCorrelate( + messageId: recent.messageId, + channelSecret: recent.channelSecret, + timestamp: recent.timestamp, + txtType: recent.txtType, + text: recent.text, + candidates: [frame], + ); + } + } + + /// Core correlation: try to match [text] (re-encrypted with all 4 possible + /// `attempt` values) against any of [candidates]' ciphertext. On a match, + /// record every raw frame sharing that match's packetHash as an observed + /// path for [messageId] -- see class docs on _correlateChannelMessagePaths + /// for why this is unambiguous. + Future _tryCorrelate({ + required String messageId, + required Uint8List channelSecret, + required int timestamp, + required int txtType, + required String text, + required List candidates, + }) async { + if (candidates.isEmpty) return; + try { + RawPacketFrame? matched; + for (var attempt = 0; attempt < 4 && matched == null; attempt++) { + final expected = encryptChannelMessage( + channelSecret: channelSecret, + timestamp: timestamp, + attempt: attempt, + txtType: txtType, + text: text, + ); + for (final candidate in candidates) { + // GRP_TXT payload is [chan_hash:1][mac:2][ciphertext:N] -- strip + // the 3-byte prefix before comparing against our re-encrypted + // plaintext, which is ciphertext only. + if (candidate.payload.length < 3) continue; + final ciphertext = candidate.payload.sublist(3); + if (_bytesEqual(ciphertext, expected)) { + matched = candidate; + break; + } + } + } + + if (matched == null) return; + + final siblings = _rawPacketLog.byPacketHash(matched.packetHash); + // Correlation can run more than once for the same message (delivered + // via PUSH and then again via a later sync, same as other message + // types already handle elsewhere in this file; plus this method is + // now called both on message-arrival and on new-frame-arrival). + // insertPath() ignores conflicts on the (messageId, pathBytes) unique + // constraint, so this is safe even if run concurrently -- no need for + // an application-side check-then-insert here. + for (final frame in siblings) { + await _database.messagePathsDao.insertPath(MessagePathsCompanion.insert( + messageId: messageId, + pathByte: frame.pathByte, + pathBytes: frame.pathBytes, + snr: drift.Value(frame.snr), + rssi: drift.Value(frame.rssi), + receivedAt: frame.receivedAtMs, + )); + } + debugPrint( + '[MessagePath] βœ… Correlated $messageId with ${siblings.length} path(s)'); + } catch (e) { + debugPrint('[MessagePath] ⚠️ Correlation error: $e'); + } + } + + void _pruneRecentChannelMessages() { + final cutoff = DateTime.now() + .subtract(const Duration(seconds: 60)) + .millisecondsSinceEpoch; + _recentChannelMessages.removeWhere((m) => m.insertedAtMs < cutoff); + } + + bool _bytesEqual(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + static const double _duplicateWaypointLocationRadiusMeters = 20; bool _shouldSuppressWaypointKey(String key) { @@ -2106,6 +2296,27 @@ class MessageRepository { } } +/// A recently-decoded channel message, remembered briefly so a multi-path +/// sibling raw frame arriving after the fact can still be correlated -- +/// see MessageRepository._tryCorrelateNewFrame. +class _RecentChannelMessage { + final String messageId; + final Uint8List channelSecret; + final int timestamp; + final int txtType; + final String text; + final int insertedAtMs; + + _RecentChannelMessage({ + required this.messageId, + required this.channelSecret, + required this.timestamp, + required this.txtType, + required this.text, + required this.insertedAtMs, + }); +} + /// Tracks in-flight retry state for a single direct message. class _PendingRetry { Timer? timer; diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 8f47385..31b6a75 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -20,6 +20,8 @@ import '../repositories/channel_repository.dart'; import '../repositories/message_repository.dart'; import '../services/message_notification_service.dart'; import '../widgets/chat_message_text.dart'; +import '../widgets/message_hop_badge.dart'; +import '../widgets/message_path_sheet.dart'; import '../widgets/status_bar_actions.dart'; import '../models/app_settings.dart'; import '../services/settings_service.dart'; @@ -140,7 +142,9 @@ class _ChannelChatScreenState extends State { children: [ Text(widget.channel.name), Text( - widget.channel.isPublic ? l10n.publicChannel : l10n.privateChannel, + widget.channel.isPublic + ? l10n.publicChannel + : l10n.privateChannel, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -224,11 +228,9 @@ class _ChannelChatScreenState extends State { padding: const EdgeInsets.all(16), itemCount: _messages.length, itemBuilder: (context, index) { - final message = - _messages[_messages.length - 1 - index]; - final showUnreadDivider = - _firstUnreadTimestamp != null && - message.timestamp == _firstUnreadTimestamp; + final message = _messages[_messages.length - 1 - index]; + final showUnreadDivider = _firstUnreadTimestamp != null && + message.timestamp == _firstUnreadTimestamp; return Column( children: [ @@ -265,8 +267,7 @@ class _ChannelChatScreenState extends State { Text( '$_newMessageCount new ${_newMessageCount == 1 ? 'message' : 'messages'}', style: theme.textTheme.labelMedium?.copyWith( - color: - theme.colorScheme.onPrimaryContainer, + color: theme.colorScheme.onPrimaryContainer, ), ), const SizedBox(width: 4), @@ -432,7 +433,9 @@ class _ChannelChatScreenState extends State { await Clipboard.setData(ClipboardData(text: link)); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.linkCopied)), + SnackBar( + content: + Text(AppLocalizations.of(context)!.linkCopied)), ); } }, @@ -507,71 +510,88 @@ class _ChannelChatScreenState extends State { ? (d) => _showMessageActions(message, senderName, isFromMe) : null, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - margin: EdgeInsets.only( - left: isFromMe ? 48 : 0, - right: isFromMe ? 0 : 48, - ), - decoration: BoxDecoration( - color: isFromMe - ? theme.colorScheme.primaryContainer - : theme.colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(18).copyWith( - bottomRight: isFromMe ? const Radius.circular(4) : null, - bottomLeft: !isFromMe ? const Radius.circular(4) : null, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + margin: EdgeInsets.only( + left: isFromMe ? 48 : 0, + right: isFromMe ? 0 : 48, ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isFromMe) ...[ - Text( - senderName, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, + decoration: BoxDecoration( + color: isFromMe + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(18).copyWith( + bottomRight: isFromMe ? const Radius.circular(4) : null, + bottomLeft: !isFromMe ? const Radius.circular(4) : null, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isFromMe) ...[ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + senderName, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + if (message.hopCount != null) ...[ + const SizedBox(width: 4), + MessageHopBadge( + messageId: message.id, + fallbackHopCount: message.hopCount!, + style: theme.textTheme.bodySmall?.copyWith( + color: + theme.colorScheme.primary.withOpacity(0.6), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + ], + ChatMessageText( + text: message.content, + style: theme.textTheme.bodyMedium?.copyWith( + color: isFromMe + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 4), - ], - ChatMessageText( - text: message.content, - style: theme.textTheme.bodyMedium?.copyWith( - color: isFromMe - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - formatMessageTime(timestamp), - style: theme.textTheme.bodySmall?.copyWith( - color: (isFromMe - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant) - .withOpacity(0.7), - ), - ), - if (isFromMe && message.deliveryStatus != null) ...[ - const SizedBox(width: 4), - Icon( - _getStatusIcon(message.deliveryStatus!), - size: 14, - color: (isFromMe - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant) - .withOpacity(0.7), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + formatMessageTime(timestamp), + style: theme.textTheme.bodySmall?.copyWith( + color: (isFromMe + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant) + .withOpacity(0.7), + ), ), + if (isFromMe && message.deliveryStatus != null) ...[ + const SizedBox(width: 4), + Icon( + _getStatusIcon(message.deliveryStatus!), + size: 14, + color: (isFromMe + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant) + .withOpacity(0.7), + ), + ], ], - ], - ), - ], + ), + ], + ), ), ), - ), ), ], ), @@ -590,7 +610,8 @@ class _ChannelChatScreenState extends State { _inputFocusNode.requestFocus(); } - void _showMessageActions(MessageData message, String senderName, bool isFromMe) { + void _showMessageActions( + MessageData message, String senderName, bool isFromMe) { showModalBottomSheet( context: context, builder: (ctx) => SafeArea( @@ -620,6 +641,24 @@ class _ChannelChatScreenState extends State { _seedReply(senderName); }, ), + if (!isFromMe && message.hopCount != null) + ListTile( + leading: const Icon(Icons.alt_route), + title: Text(AppLocalizations.of(context)!.messagePath), + onTap: () { + Navigator.pop(ctx); + showModalBottomSheet( + context: context, + builder: (_) => MessagePathSheet( + messageId: message.id, + senderName: senderName, + hopCount: message.hopCount!, + timestamp: DateTime.fromMillisecondsSinceEpoch( + message.timestamp), + ), + ); + }, + ), ], ), ), @@ -761,7 +800,10 @@ class _ChannelChatScreenState extends State { // Clear input immediately _messageController.clear(); - setState(() { _mentionSuggestions = []; _messages = _allMessages; }); + setState(() { + _mentionSuggestions = []; + _messages = _allMessages; + }); if (!Platform.isAndroid && !Platform.isIOS) _inputFocusNode.requestFocus(); // Scroll to bottom @@ -801,7 +843,8 @@ class _ChannelChatScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.genericError(e.toString())), + content: + Text(AppLocalizations.of(context)!.genericError(e.toString())), backgroundColor: Colors.red, ), ); diff --git a/lib/screens/direct_message_screen.dart b/lib/screens/direct_message_screen.dart index f7abe7d..0bb891b 100644 --- a/lib/screens/direct_message_screen.dart +++ b/lib/screens/direct_message_screen.dart @@ -17,6 +17,8 @@ import '../repositories/message_repository.dart'; import '../services/message_notification_service.dart'; import '../utils/message_time_format.dart'; import '../widgets/chat_message_text.dart'; +import '../widgets/message_hop_badge.dart'; +import '../widgets/message_path_sheet.dart'; import '../widgets/status_bar_actions.dart'; /// Direct message chat screen for one-on-one conversations @@ -200,11 +202,9 @@ class _DirectMessageScreenState extends State { padding: const EdgeInsets.all(16), itemCount: _messages.length, itemBuilder: (context, index) { - final message = - _messages[_messages.length - 1 - index]; - final showUnreadDivider = - _firstUnreadTimestamp != null && - message.timestamp == _firstUnreadTimestamp; + final message = _messages[_messages.length - 1 - index]; + final showUnreadDivider = _firstUnreadTimestamp != null && + message.timestamp == _firstUnreadTimestamp; return Column( children: [ @@ -302,9 +302,11 @@ class _DirectMessageScreenState extends State { textInputAction: TextInputAction.send, onSubmitted: (_) => _sendMessage(), onChanged: (text) { - if (text.isNotEmpty && _firstUnreadTimestamp != null) { + if (text.isNotEmpty && + _firstUnreadTimestamp != null) { _messageRepository.messagesDao - .markContactMessagesAsRead(widget.contact.hash); + .markContactMessagesAsRead( + widget.contact.hash); setState(() { _firstUnreadTimestamp = null; }); @@ -361,67 +363,79 @@ class _DirectMessageScreenState extends State { Flexible( child: GestureDetector( onLongPress: (Platform.isAndroid || Platform.isIOS) - ? () => _showMessageActions(message) + ? () => _showMessageActions(message, isFromMe) : null, onSecondaryTapDown: (!Platform.isAndroid && !Platform.isIOS) - ? (d) => _showMessageActions(message) + ? (d) => _showMessageActions(message, isFromMe) : null, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - margin: EdgeInsets.only( - left: isFromMe ? 48 : 0, - right: isFromMe ? 0 : 48, - ), - decoration: BoxDecoration( - color: isFromMe - ? theme.colorScheme.primaryContainer - : theme.colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(18).copyWith( - bottomRight: isFromMe ? const Radius.circular(4) : null, - bottomLeft: !isFromMe ? const Radius.circular(4) : null, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + margin: EdgeInsets.only( + left: isFromMe ? 48 : 0, + right: isFromMe ? 0 : 48, ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ChatMessageText( - text: message.content, - style: theme.textTheme.bodyMedium?.copyWith( - color: isFromMe - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant, - ), + decoration: BoxDecoration( + color: isFromMe + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(18).copyWith( + bottomRight: isFromMe ? const Radius.circular(4) : null, + bottomLeft: !isFromMe ? const Radius.circular(4) : null, ), - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - formatMessageTime(timestamp), - style: theme.textTheme.bodySmall?.copyWith( - color: (isFromMe - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant) - .withOpacity(0.7), - ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ChatMessageText( + text: message.content, + style: theme.textTheme.bodyMedium?.copyWith( + color: isFromMe + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant, ), - if (isFromMe && message.deliveryStatus != null) ...[ - const SizedBox(width: 4), - Icon( - _getStatusIcon(message.deliveryStatus!), - size: 14, - color: (isFromMe - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant) - .withOpacity(0.7), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isFromMe && message.hopCount != null) ...[ + MessageHopBadge( + messageId: message.id, + fallbackHopCount: message.hopCount!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant + .withOpacity(0.7), + ), + ), + const SizedBox(width: 4), + ], + Text( + formatMessageTime(timestamp), + style: theme.textTheme.bodySmall?.copyWith( + color: (isFromMe + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant) + .withOpacity(0.7), + ), ), + if (isFromMe && message.deliveryStatus != null) ...[ + const SizedBox(width: 4), + Icon( + _getStatusIcon(message.deliveryStatus!), + size: 14, + color: (isFromMe + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant) + .withOpacity(0.7), + ), + ], ], - ], - ), - ], + ), + ], + ), ), ), - ), ), ], ), @@ -504,7 +518,8 @@ class _DirectMessageScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.directMessagesDisabledForRepeaters), + content: Text(AppLocalizations.of(context)! + .directMessagesDisabledForRepeaters), ), ); } @@ -558,7 +573,8 @@ class _DirectMessageScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.genericError(e.toString())), + content: + Text(AppLocalizations.of(context)!.genericError(e.toString())), backgroundColor: Colors.red, ), ); @@ -566,7 +582,7 @@ class _DirectMessageScreenState extends State { } } - void _showMessageActions(MessageData message) { + void _showMessageActions(MessageData message, bool isFromMe) { showModalBottomSheet( context: context, builder: (ctx) => SafeArea( @@ -587,6 +603,24 @@ class _DirectMessageScreenState extends State { ); }, ), + if (!isFromMe && message.hopCount != null) + ListTile( + leading: const Icon(Icons.alt_route), + title: Text(AppLocalizations.of(context)!.messagePath), + onTap: () { + Navigator.pop(ctx); + showModalBottomSheet( + context: context, + builder: (_) => MessagePathSheet( + messageId: message.id, + senderName: widget.contact.name ?? 'Unknown Contact', + hopCount: message.hopCount!, + timestamp: DateTime.fromMillisecondsSinceEpoch( + message.timestamp), + ), + ); + }, + ), ], ), ), diff --git a/lib/services/channel_crypto.dart b/lib/services/channel_crypto.dart new file mode 100644 index 0000000..f3b877b --- /dev/null +++ b/lib/services/channel_crypto.dart @@ -0,0 +1,55 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:pointycastle/export.dart'; + +/// Re-implements MeshCore firmware's channel-message encryption +/// (`Utils::encrypt`, AES-128 in raw ECB mode, key = 16-byte channel +/// secret) so a message we already know the plaintext of (decoded for us +/// by firmware) can be re-encrypted and byte-matched against a raw +/// PUSH_LOG_RX_DATA packet's ciphertext -- an exact, unambiguous way to +/// find which raw reception(s) a decoded message came from, without +/// needing to decrypt anything ourselves. +/// +/// Plaintext layout (over-the-air, before firmware repacks it for the +/// companion app's V3 message response): 4-byte little-endian timestamp, +/// 1 byte packing `attempt` (bits 0-1) and `txtType` (bits 2-7), then the +/// UTF-8 text. Encrypted in raw ECB blocks with no chaining; only the +/// trailing partial block is zero-padded -- an exact multiple of 16 bytes +/// gets no extra padding block, matching Utils::encrypt exactly. +Uint8List encryptChannelMessage({ + required Uint8List channelSecret, + required int timestamp, + required int attempt, + required int txtType, + required String text, +}) { + final textBytes = utf8.encode(text); + final plaintext = Uint8List(5 + textBytes.length); + final byteData = ByteData.view(plaintext.buffer); + byteData.setUint32(0, timestamp, Endian.little); + plaintext[4] = (attempt & 0x03) | ((txtType & 0x3F) << 2); + plaintext.setRange(5, plaintext.length, textBytes); + + final cipher = ECBBlockCipher(AESEngine()) + ..init(true, KeyParameter(channelSecret)); + + final fullBlocks = plaintext.length ~/ 16; + final remainder = plaintext.length % 16; + final outLen = (fullBlocks + (remainder > 0 ? 1 : 0)) * 16; + final out = Uint8List(outLen); + + for (var i = 0; i < fullBlocks; i++) { + cipher.processBlock(plaintext, i * 16, out, i * 16); + } + if (remainder > 0) { + final padded = Uint8List(16); + padded.setRange(0, remainder, plaintext, fullBlocks * 16); + cipher.processBlock(padded, 0, out, fullBlocks * 16); + } + + return out; +} diff --git a/lib/utils/radio_path_utils.dart b/lib/utils/radio_path_utils.dart new file mode 100644 index 0000000..32b18a6 --- /dev/null +++ b/lib/utils/radio_path_utils.dart @@ -0,0 +1,46 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 + +import 'dart:typed_data'; + +/// Decodes a raw wire path_length byte into (hopCount, hashSize), per +/// docs/packet_format.md: bits 0-5 are hop count, bits 6-7 are hash-size +/// mode (hashSize = mode + 1, so 1/2/3 bytes per hop; mode 3 is reserved). +({int hopCount, int hashSize}) decodePathByte(int pathByte) { + final hopCount = pathByte & 0x3F; + final hashSize = ((pathByte & 0xC0) >> 6) + 1; + return (hopCount: hopCount, hashSize: hashSize); +} + +/// Splits raw path bytes into per-hop identifier chunks, using the hop +/// count/hash size encoded in [pathByte]. Falls back to 1-byte chunks if +/// the byte counts don't line up (shouldn't happen for well-formed data, +/// but keeps this robust against unexpected input). +List splitPathHops(int pathByte, Uint8List pathBytes) { + final decoded = decodePathByte(pathByte); + if (decoded.hopCount == 0 || pathBytes.isEmpty) return const []; + + final expectedLen = decoded.hopCount * decoded.hashSize; + final hashSize = expectedLen == pathBytes.length ? decoded.hashSize : 1; + + final hops = []; + for (var i = 0; i + hashSize <= pathBytes.length; i += hashSize) { + hops.add(pathBytes.sublist(i, i + hashSize)); + } + return hops; +} + +/// Formats a hop count for display: "Direct" for 0, "N hop(s)" otherwise. +/// Callers needing localized text should use AppLocalizations directly; +/// this is for the compact non-localized summary badge only (see +/// formatHopCountsBadge). +String hopCountBadgeToken(int hopCount) => hopCount == 0 ? 'd' : '$hopCount'; + +/// Compact multi-path summary badge, e.g. "d/1" for a message heard both +/// directly and via one relay hop. Mirrors the reference client's +/// formatHopCounts: sorted ascending, direct shown as "d". +String formatHopCountsBadge(List hopCounts) { + if (hopCounts.isEmpty) return ''; + final sorted = [...hopCounts]..sort(); + return sorted.map(hopCountBadgeToken).join('/'); +} diff --git a/lib/widgets/message_hop_badge.dart b/lib/widgets/message_hop_badge.dart new file mode 100644 index 0000000..9dbd57f --- /dev/null +++ b/lib/widgets/message_hop_badge.dart @@ -0,0 +1,46 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../repositories/message_repository.dart'; +import '../utils/radio_path_utils.dart'; + +/// Inline `(d)` / `(N)` / `(d/1)` hop badge for a message bubble. +/// +/// Renders the Phase 1 single-value summary (from [fallbackHopCount], +/// firmware's own hopCount for this message) immediately, then swaps to the +/// real multi-path summary (e.g. "d/1" for a message heard both directly +/// and via a relay) once raw-frame correlation data loads, if any exists. +/// No loading flicker: the fallback is itself a valid, correct answer, just +/// potentially less complete. +class MessageHopBadge extends StatelessWidget { + final String messageId; + final int fallbackHopCount; + final TextStyle? style; + + const MessageHopBadge({ + super.key, + required this.messageId, + required this.fallbackHopCount, + this.style, + }); + + @override + Widget build(BuildContext context) { + final fallback = '(${hopCountBadgeToken(fallbackHopCount)})'; + return FutureBuilder( + future: context.read().getMessagePaths(messageId), + builder: (context, snapshot) { + final paths = snapshot.data; + if (paths == null || paths.isEmpty) { + return Text(fallback, style: style); + } + final hopCounts = + paths.map((p) => decodePathByte(p.pathByte).hopCount).toList(); + return Text('(${formatHopCountsBadge(hopCounts)})', style: style); + }, + ); + } +} diff --git a/lib/widgets/message_path_sheet.dart b/lib/widgets/message_path_sheet.dart new file mode 100644 index 0000000..066c9db --- /dev/null +++ b/lib/widgets/message_path_sheet.dart @@ -0,0 +1,379 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../database/database.dart'; +import '../l10n/app_localizations.dart'; +import '../repositories/contact_repository.dart'; +import '../repositories/message_repository.dart'; +import '../services/settings_service.dart'; +import '../utils/message_time_format.dart'; +import '../utils/radio_path_utils.dart'; + +/// Bottom sheet showing how a message was physically received: a simple +/// Sender -> You timeline when only the message-level hop-count summary is +/// available (Messages.hopCount/snr), or, when raw-frame correlation +/// matched real radio packets (see MessageRepository.getMessagePaths), a +/// full breakdown per distinct path -- including which repeater(s) relayed +/// it, real SNR/RSSI, and every path heard at once (e.g. direct AND via a +/// relay simultaneously). +class MessagePathSheet extends StatefulWidget { + final String messageId; + final String senderName; + + /// Fallback summary (0 = direct) used only when no correlated paths + /// exist for this message. + final int hopCount; + final DateTime timestamp; + + const MessagePathSheet({ + super.key, + required this.messageId, + required this.senderName, + required this.hopCount, + required this.timestamp, + }); + + @override + State createState() => _MessagePathSheetState(); +} + +class _ResolvedHop { + final Uint8List prefix; + final List matches; + _ResolvedHop({required this.prefix, required this.matches}); +} + +class _ResolvedPath { + final MessagePathData raw; + final List<_ResolvedHop> hops; + _ResolvedPath({required this.raw, required this.hops}); +} + +class _MessagePathSheetState extends State { + List<_ResolvedPath>? _resolvedPaths; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Future _load() async { + final messageRepo = context.read(); + final contactRepo = context.read(); + final companionKey = + context.read().settings.currentCompanionPublicKey; + + final paths = await messageRepo.getMessagePaths(widget.messageId); + final resolved = <_ResolvedPath>[]; + for (final path in paths) { + final hopPrefixes = splitPathHops(path.pathByte, path.pathBytes); + final hops = <_ResolvedHop>[]; + for (final prefix in hopPrefixes) { + final matches = await contactRepo.getContactsByPublicKeyPrefix( + prefix, + prefixLength: prefix.length, + companionKey: companionKey, + ); + hops.add(_ResolvedHop(prefix: prefix, matches: matches)); + } + resolved.add(_ResolvedPath(raw: path, hops: hops)); + } + + if (mounted) setState(() => _resolvedPaths = resolved); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + final resolved = _resolvedPaths; + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: resolved == null + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + : Scrollbar( + controller: _scrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _scrollController, + child: Padding( + // Leave room so content doesn't sit under the scrollbar. + padding: const EdgeInsets.only(right: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(l10n.messagePath, + style: theme.textTheme.titleMedium), + if (resolved.length > 1) ...[ + const SizedBox(width: 8), + Text( + '(${formatHopCountsBadge(resolved.map((p) => decodePathByte(p.raw.pathByte).hopCount).toList())})', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + formatMessageTime(widget.timestamp), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + if (resolved.isEmpty) + _SinglePathTimeline( + senderName: widget.senderName, + hopCount: widget.hopCount, + ) + else + for (var i = 0; i < resolved.length; i++) ...[ + if (i > 0) const SizedBox(height: 20), + if (resolved.length > 1) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + l10n.pathNumber(i + 1), + style: theme.textTheme.labelLarge, + ), + ), + _ResolvedPathTimeline( + senderName: widget.senderName, + path: resolved[i], + ), + ], + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +/// Fallback timeline for when no raw-frame correlation exists -- just the +/// message-level hop-count summary firmware already gives us. +class _SinglePathTimeline extends StatelessWidget { + final String senderName; + final int hopCount; + + const _SinglePathTimeline({required this.senderName, required this.hopCount}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + final isDirect = hopCount == 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TimelineNode(label: senderName, color: theme.colorScheme.primary), + _TimelineConnector( + label: isDirect ? l10n.hopDirect : l10n.hopsCount(hopCount), + color: theme.colorScheme.outline, + ), + _TimelineNode(label: 'You', color: theme.colorScheme.primary), + ], + ); + } +} + +/// Full timeline for a correlated path: sender, each named/ambiguous/unknown +/// hop, then the receiver, with real SNR/RSSI on the connecting caption. +class _ResolvedPathTimeline extends StatelessWidget { + final String senderName; + final _ResolvedPath path; + + const _ResolvedPathTimeline({required this.senderName, required this.path}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + final decoded = decodePathByte(path.raw.pathByte); + final isDirect = decoded.hopCount == 0; + + final signalParts = []; + if (path.raw.snr != null) { + signalParts.add(l10n.snrValue((path.raw.snr! / 4.0).toStringAsFixed(1))); + } + if (path.raw.rssi != null) { + signalParts.add(l10n.rssiValue(path.raw.rssi!)); + } + final signalLabel = signalParts.join(' Β· '); + + final children = [ + _TimelineNode(label: senderName, color: theme.colorScheme.primary), + ]; + + if (isDirect) { + // No intermediate node to attach the hex/signal caption to -- this is + // the same "Direct" summary the reference layout shows at the top + // level when there's nothing between sender and receiver. + children.add(_TimelineConnector( + label: signalLabel.isEmpty + ? l10n.hopDirect + : '${l10n.hopDirect} β€” $signalLabel', + color: theme.colorScheme.outline, + )); + } else { + for (var i = 0; i < path.hops.length; i++) { + final hop = path.hops[i]; + final hex = _hopHex(hop); + // Plain connecting segment -- the hex/hop-number identifies the + // node it leads to, so it's a caption on that node, not a label on + // the segment leading to it. + children.add( + _TimelineConnector(label: '', color: theme.colorScheme.outline)); + children.add(_TimelineNode( + caption: l10n.hopLabel(i + 1, hex), + label: _hopName(l10n, hop, hex), + color: hop.matches.length == 1 + ? theme.colorScheme.secondary + : theme.colorScheme.error, + )); + } + // Final segment, into the receiver -- this is "the last hop, as heard + // by you" regardless of how many relays preceded it, so the signal + // reading belongs here. + children.add(_TimelineConnector( + label: signalLabel, color: theme.colorScheme.outline)); + } + + children.add(_TimelineNode(label: 'You', color: theme.colorScheme.primary)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ); + } + + /// Hex string of a hop's raw path-byte identifier, per the reference + /// layout (e.g. "6C") -- shown on the connector regardless of whether the + /// hop resolves to a known contact. + String _hopHex(_ResolvedHop hop) { + return hop.prefix + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); + } + + /// Resolved display name for a hop's node: the contact name if there's + /// exactly one match, an ambiguous-candidates note if more than one + /// shares this hex prefix, or the hex itself again if unknown. + String _hopName(AppLocalizations l10n, _ResolvedHop hop, String hex) { + if (hop.matches.isEmpty) return hex; + if (hop.matches.length > 1) { + return l10n + .ambiguousHop(hop.matches.map((c) => c.name ?? '?').join(', ')); + } + return hop.matches.first.name ?? hex; + } +} + +class _TimelineNode extends StatelessWidget { + final String label; + final Color color; + + /// Small label above [label] identifying the node's role and raw + /// identifier (e.g. "Hop 1: 6C") -- matches the reference layout, where + /// this caption belongs to the node it identifies, not the segment + /// leading to it. + final String? caption; + + const _TimelineNode({required this.label, required this.color, this.caption}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 4), + child: CircleAvatar(radius: 4, backgroundColor: color), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (caption != null) + Text( + caption!, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text(label, style: theme.textTheme.bodyMedium), + ], + ), + ), + ], + ); + } +} + +class _TimelineConnector extends StatelessWidget { + final String label; + final Color color; + + const _TimelineConnector({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 3.5), + child: Container(width: 1, height: 28, color: color), + ), + const SizedBox(width: 20.5), + if (label.isNotEmpty) + Flexible( + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9f0aeb7..35fd96c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -737,10 +737,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -753,10 +753,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -949,6 +949,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: "direct main" + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" polylabel: dependency: transitive description: @@ -1270,10 +1278,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" timezone: dependency: transitive description: @@ -1459,5 +1467,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1566550..a0e4ad8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: # Utilities crypto: ^3.0.3 + pointycastle: ^4.0.0 http: ^1.2.0 package_info_plus: ^8.0.0 battery_plus: ^6.2.0