diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 920f7ab..06b85eb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -112,5 +112,12 @@ + + + + + + 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/daos/messages_dao.dart b/lib/database/daos/messages_dao.dart index 5ce6928..cf3da51 100644 --- a/lib/database/daos/messages_dao.dart +++ b/lib/database/daos/messages_dao.dart @@ -19,14 +19,17 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { MessagesDao(super.db); - /// Get all messages for a channel, ordered by timestamp + /// Strict local receive order. Messages has no INTEGER PRIMARY KEY, so + /// SQLite maintains an implicit rowid that's atomically assigned in + /// insertion order -- immune to the sender-embedded (and untrusted) + /// `timestamp` column, with no app-side bookkeeping required. + static const Expression _rowid = CustomExpression('rowid'); + + /// Get all messages for a channel, in local receive order Future> getMessagesByChannel(int channelHash) { return (select(messages) ..where((t) => t.channelHash.equals(channelHash)) - ..orderBy([ - (t) => - OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc), - ])) + ..orderBy([(t) => OrderingTerm(expression: _rowid)])) .get(); } @@ -37,10 +40,7 @@ class MessagesDao extends DatabaseAccessor ..where((t) => t.channelHash.equals(channelHash) & t.companionDeviceKey.equals(companionKey)) - ..orderBy([ - (t) => - OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc), - ])) + ..orderBy([(t) => OrderingTerm(expression: _rowid)])) .get(); } @@ -50,10 +50,7 @@ class MessagesDao extends DatabaseAccessor return (select(messages) ..where((t) => t.isPrivate.equals(true) & t.channelHash.equals(contactHash)) - ..orderBy([ - (t) => - OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc), - ])) + ..orderBy([(t) => OrderingTerm(expression: _rowid)])) .get(); } @@ -65,10 +62,7 @@ class MessagesDao extends DatabaseAccessor t.isPrivate.equals(true) & t.channelHash.equals(contactHash) & t.companionDeviceKey.equals(companionKey)) - ..orderBy([ - (t) => - OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc), - ])) + ..orderBy([(t) => OrderingTerm(expression: _rowid)])) .get(); } @@ -77,10 +71,16 @@ class MessagesDao extends DatabaseAccessor return (select(messages)..where((t) => t.id.equals(id))).getSingleOrNull(); } - /// Insert a new message and return the inserted data + /// Insert a new message and return the inserted data. + /// + /// `receivedAt` (this device's own clock) is always assigned here, not by + /// callers -- ordering itself comes from SQLite's rowid, assigned + /// atomically by the single INSERT below. Future insertMessage(MessagesCompanion message) async { try { - await into(messages).insert(message); + await into(messages).insert(message.copyWith( + receivedAt: Value(DateTime.now().millisecondsSinceEpoch), + )); // Query the inserted message by ID return await getMessageById(message.id.value); } catch (e) { @@ -143,8 +143,7 @@ class MessagesDao extends DatabaseAccessor return (select(messages) ..where((t) => t.deliveryStatus.equals('SENDING')) ..orderBy([ - (t) => - OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc), + (t) => OrderingTerm(expression: _rowid), ])) .get(); } @@ -154,8 +153,7 @@ class MessagesDao extends DatabaseAccessor return (select(messages) ..where((t) => t.isSentByMe.equals(true)) ..orderBy([ - (t) => - OrderingTerm(expression: t.timestamp, mode: OrderingMode.desc), + (t) => OrderingTerm(expression: _rowid, mode: OrderingMode.desc), ])) .get(); } @@ -200,8 +198,7 @@ class MessagesDao extends DatabaseAccessor return (select(messages) ..where((t) => t.channelHash.equals(channelHash)) ..orderBy([ - (t) => - OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc), + (t) => OrderingTerm(expression: _rowid), ])) .watch(); } @@ -214,8 +211,7 @@ class MessagesDao extends DatabaseAccessor t.channelHash.equals(channelHash) & t.companionDeviceKey.equals(companionKey)) ..orderBy([ - (t) => - OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc), + (t) => OrderingTerm(expression: _rowid), ])) .watch(); } @@ -227,8 +223,7 @@ class MessagesDao extends DatabaseAccessor ..where((t) => t.isPrivate.equals(true) & t.channelHash.equals(contactHash)) ..orderBy([ - (t) => - OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc), + (t) => OrderingTerm(expression: _rowid), ])) .watch(); } @@ -242,8 +237,7 @@ class MessagesDao extends DatabaseAccessor t.channelHash.equals(contactHash) & t.companionDeviceKey.equals(companionKey)) ..orderBy([ - (t) => - OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc), + (t) => OrderingTerm(expression: _rowid), ])) .watch(); } @@ -259,8 +253,7 @@ class MessagesDao extends DatabaseAccessor return (select(messages) ..where((t) => t.deliveryStatus.equals('SENDING')) ..orderBy([ - (t) => - OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc), + (t) => OrderingTerm(expression: _rowid), ])) .watch(); } @@ -270,8 +263,7 @@ class MessagesDao extends DatabaseAccessor Stream> watchAllMessages() { return (select(messages) ..orderBy([ - (t) => - OrderingTerm(expression: t.timestamp, mode: OrderingMode.desc), + (t) => OrderingTerm(expression: _rowid, mode: OrderingMode.desc), ])) .watch(); } @@ -448,38 +440,34 @@ class MessagesDao extends DatabaseAccessor )); } - /// Get the first unread message timestamp for a channel (for divider) - Future getFirstUnreadTimestampByChannel(int channelHash) async { + /// Get the ID of the first unread message for a channel (for divider) + Future getFirstUnreadMessageIdByChannel(int channelHash) async { final query = select(messages) ..where((t) => t.channelHash.equals(channelHash) & t.isPrivate.equals(false) & t.isRead.equals(false) & t.isSentByMe.equals(false)) - ..orderBy([ - (t) => OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc) - ]) + ..orderBy([(t) => OrderingTerm(expression: _rowid)]) ..limit(1); final result = await query.getSingleOrNull(); - return result?.timestamp; + return result?.id; } - /// Get the first unread message timestamp for a contact (for divider) - Future getFirstUnreadTimestampByContact(int contactHash) async { + /// Get the ID of the first unread message for a contact (for divider) + Future getFirstUnreadMessageIdByContact(int contactHash) async { final query = select(messages) ..where((t) => t.channelHash.equals(contactHash) & t.isPrivate.equals(true) & t.isRead.equals(false) & t.isSentByMe.equals(false)) - ..orderBy([ - (t) => OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc) - ]) + ..orderBy([(t) => OrderingTerm(expression: _rowid)]) ..limit(1); final result = await query.getSingleOrNull(); - return result?.timestamp; + return result?.id; } /// Get total unread count across all channels diff --git a/lib/database/database.dart b/lib/database/database.dart index 3de9b72..c61de2b 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,27 @@ 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, + // the message_paths table for multi-path routing detail, and a + // local receivedAt display time independent of the untrusted + // sender-embedded timestamp. Ordering uses SQLite's implicit + // rowid, not a dedicated column. + if (from <= 8 && to >= 9) { + await m.addColumn(messages, messages.hopCount); + await m.addColumn(messages, messages.snr); + await m.addColumn(messages, messages.receivedAt); + await m.createTable(messagePaths); + // True historical receive time isn't recoverable for existing + // rows -- best-effort backfill from the existing (untrusted) + // timestamp. Only affects rows inserted before this migration. + await customStatement( + 'UPDATE messages SET received_at = timestamp'); + print( + '[Migration] v8->v9: added hopCount/snr/receivedAt to messages, created message_paths table'); } }, ); diff --git a/lib/database/database.g.dart b/lib/database/database.g.dart index 056f521..b36ebae 100644 --- a/lib/database/database.g.dart +++ b/lib/database/database.g.dart @@ -1547,6 +1547,25 @@ 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); + static const VerificationMeta _receivedAtMeta = + const VerificationMeta('receivedAt'); + @override + late final GeneratedColumn receivedAt = GeneratedColumn( + 'received_at', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0)); @override List get $columns => [ id, @@ -1562,7 +1581,10 @@ class $MessagesTable extends Messages attempt, isSentByMe, isRead, - companionDeviceKey + companionDeviceKey, + hopCount, + snr, + receivedAt ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1659,6 +1681,20 @@ 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)); + } + if (data.containsKey('received_at')) { + context.handle( + _receivedAtMeta, + receivedAt.isAcceptableOrUnknown( + data['received_at']!, _receivedAtMeta)); + } return context; } @@ -1696,6 +1732,12 @@ 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']), + receivedAt: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}received_at'])!, ); } @@ -1720,6 +1762,9 @@ class MessageData extends DataClass implements Insertable { final bool isSentByMe; final bool isRead; final String? companionDeviceKey; + final int? hopCount; + final int? snr; + final int receivedAt; const MessageData( {required this.id, required this.senderId, @@ -1734,7 +1779,10 @@ class MessageData extends DataClass implements Insertable { required this.attempt, required this.isSentByMe, required this.isRead, - this.companionDeviceKey}); + this.companionDeviceKey, + this.hopCount, + this.snr, + required this.receivedAt}); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -1758,6 +1806,13 @@ 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); + } + map['received_at'] = Variable(receivedAt); return map; } @@ -1783,6 +1838,11 @@ 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), + receivedAt: Value(receivedAt), ); } @@ -1805,6 +1865,9 @@ 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']), + receivedAt: serializer.fromJson(json['receivedAt']), ); } @override @@ -1825,6 +1888,9 @@ 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), + 'receivedAt': serializer.toJson(receivedAt), }; } @@ -1842,7 +1908,10 @@ 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(), + int? receivedAt}) => MessageData( id: id ?? this.id, senderId: senderId ?? this.senderId, @@ -1860,6 +1929,9 @@ 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, + receivedAt: receivedAt ?? this.receivedAt, ); MessageData copyWithCompanion(MessagesCompanion data) { return MessageData( @@ -1887,6 +1959,10 @@ 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, + receivedAt: + data.receivedAt.present ? data.receivedAt.value : this.receivedAt, ); } @@ -1906,7 +1982,10 @@ 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('receivedAt: $receivedAt') ..write(')')) .toString(); } @@ -1926,7 +2005,10 @@ class MessageData extends DataClass implements Insertable { attempt, isSentByMe, isRead, - companionDeviceKey); + companionDeviceKey, + hopCount, + snr, + receivedAt); @override bool operator ==(Object other) => identical(this, other) || @@ -1944,7 +2026,10 @@ 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 && + other.receivedAt == this.receivedAt); } class MessagesCompanion extends UpdateCompanion { @@ -1962,6 +2047,9 @@ class MessagesCompanion extends UpdateCompanion { final Value isSentByMe; final Value isRead; final Value companionDeviceKey; + final Value hopCount; + final Value snr; + final Value receivedAt; final Value rowid; const MessagesCompanion({ this.id = const Value.absent(), @@ -1978,6 +2066,9 @@ 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.receivedAt = const Value.absent(), this.rowid = const Value.absent(), }); MessagesCompanion.insert({ @@ -1995,6 +2086,9 @@ 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.receivedAt = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), senderId = Value(senderId), @@ -2019,6 +2113,9 @@ class MessagesCompanion extends UpdateCompanion { Expression? isSentByMe, Expression? isRead, Expression? companionDeviceKey, + Expression? hopCount, + Expression? snr, + Expression? receivedAt, Expression? rowid, }) { return RawValuesInsertable({ @@ -2037,6 +2134,9 @@ 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 (receivedAt != null) 'received_at': receivedAt, if (rowid != null) 'rowid': rowid, }); } @@ -2056,6 +2156,9 @@ class MessagesCompanion extends UpdateCompanion { Value? isSentByMe, Value? isRead, Value? companionDeviceKey, + Value? hopCount, + Value? snr, + Value? receivedAt, Value? rowid}) { return MessagesCompanion( id: id ?? this.id, @@ -2072,6 +2175,9 @@ class MessagesCompanion extends UpdateCompanion { isSentByMe: isSentByMe ?? this.isSentByMe, isRead: isRead ?? this.isRead, companionDeviceKey: companionDeviceKey ?? this.companionDeviceKey, + hopCount: hopCount ?? this.hopCount, + snr: snr ?? this.snr, + receivedAt: receivedAt ?? this.receivedAt, rowid: rowid ?? this.rowid, ); } @@ -2080,71 +2186,460 @@ class MessagesCompanion extends UpdateCompanion { Map toColumns(bool nullToAbsent) { final map = {}; if (id.present) { - map['id'] = Variable(id.value); - } - if (senderId.present) { - map['sender_id'] = Variable(senderId.value); - } - if (senderName.present) { - map['sender_name'] = Variable(senderName.value); - } - if (channelHash.present) { - map['channel_hash'] = Variable(channelHash.value); - } - if (content.present) { - map['content'] = Variable(content.value); - } - if (timestamp.present) { - map['timestamp'] = Variable(timestamp.value); - } - if (isPrivate.present) { - map['is_private'] = Variable(isPrivate.value); - } - if (ackChecksum.present) { - map['ack_checksum'] = Variable(ackChecksum.value); - } - if (deliveryStatus.present) { - map['delivery_status'] = Variable(deliveryStatus.value); + map['id'] = Variable(id.value); + } + if (senderId.present) { + map['sender_id'] = Variable(senderId.value); + } + if (senderName.present) { + map['sender_name'] = Variable(senderName.value); + } + if (channelHash.present) { + map['channel_hash'] = Variable(channelHash.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (isPrivate.present) { + map['is_private'] = Variable(isPrivate.value); + } + if (ackChecksum.present) { + map['ack_checksum'] = Variable(ackChecksum.value); + } + if (deliveryStatus.present) { + map['delivery_status'] = Variable(deliveryStatus.value); + } + if (heardByCount.present) { + map['heard_by_count'] = Variable(heardByCount.value); + } + if (attempt.present) { + map['attempt'] = Variable(attempt.value); + } + if (isSentByMe.present) { + map['is_sent_by_me'] = Variable(isSentByMe.value); + } + if (isRead.present) { + map['is_read'] = Variable(isRead.value); + } + 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 (receivedAt.present) { + map['received_at'] = Variable(receivedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MessagesCompanion(') + ..write('id: $id, ') + ..write('senderId: $senderId, ') + ..write('senderName: $senderName, ') + ..write('channelHash: $channelHash, ') + ..write('content: $content, ') + ..write('timestamp: $timestamp, ') + ..write('isPrivate: $isPrivate, ') + ..write('ackChecksum: $ackChecksum, ') + ..write('deliveryStatus: $deliveryStatus, ') + ..write('heardByCount: $heardByCount, ') + ..write('attempt: $attempt, ') + ..write('isSentByMe: $isSentByMe, ') + ..write('isRead: $isRead, ') + ..write('companionDeviceKey: $companionDeviceKey, ') + ..write('hopCount: $hopCount, ') + ..write('snr: $snr, ') + ..write('receivedAt: $receivedAt, ') + ..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 (heardByCount.present) { - map['heard_by_count'] = Variable(heardByCount.value); + if (messageId.present) { + map['message_id'] = Variable(messageId.value); } - if (attempt.present) { - map['attempt'] = Variable(attempt.value); + if (pathByte.present) { + map['path_byte'] = Variable(pathByte.value); } - if (isSentByMe.present) { - map['is_sent_by_me'] = Variable(isSentByMe.value); + if (pathBytes.present) { + map['path_bytes'] = Variable(pathBytes.value); } - if (isRead.present) { - map['is_read'] = Variable(isRead.value); + if (snr.present) { + map['snr'] = Variable(snr.value); } - if (companionDeviceKey.present) { - map['companion_device_key'] = Variable(companionDeviceKey.value); + if (rssi.present) { + map['rssi'] = Variable(rssi.value); } - if (rowid.present) { - map['rowid'] = Variable(rowid.value); + if (receivedAt.present) { + map['received_at'] = Variable(receivedAt.value); } return map; } @override String toString() { - return (StringBuffer('MessagesCompanion(') + return (StringBuffer('MessagePathsCompanion(') ..write('id: $id, ') - ..write('senderId: $senderId, ') - ..write('senderName: $senderName, ') - ..write('channelHash: $channelHash, ') - ..write('content: $content, ') - ..write('timestamp: $timestamp, ') - ..write('isPrivate: $isPrivate, ') - ..write('ackChecksum: $ackChecksum, ') - ..write('deliveryStatus: $deliveryStatus, ') - ..write('heardByCount: $heardByCount, ') - ..write('attempt: $attempt, ') - ..write('isSentByMe: $isSentByMe, ') - ..write('isRead: $isRead, ') - ..write('companionDeviceKey: $companionDeviceKey, ') - ..write('rowid: $rowid') + ..write('messageId: $messageId, ') + ..write('pathByte: $pathByte, ') + ..write('pathBytes: $pathBytes, ') + ..write('snr: $snr, ') + ..write('rssi: $rssi, ') + ..write('receivedAt: $receivedAt') ..write(')')) .toString(); } @@ -5863,6 +6358,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 +6374,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 +6392,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { contacts, channels, messages, + messagePaths, waypoints, companionDevices, contactDisplayStates, @@ -6533,6 +7032,9 @@ typedef $$MessagesTableCreateCompanionBuilder = MessagesCompanion Function({ required bool isSentByMe, Value isRead, Value companionDeviceKey, + Value hopCount, + Value snr, + Value receivedAt, Value rowid, }); typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ @@ -6550,6 +7052,9 @@ typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ Value isSentByMe, Value isRead, Value companionDeviceKey, + Value hopCount, + Value snr, + Value receivedAt, Value rowid, }); @@ -6605,6 +7110,15 @@ 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)); + + ColumnFilters get receivedAt => $composableBuilder( + column: $table.receivedAt, builder: (column) => ColumnFilters(column)); } class $$MessagesTableOrderingComposer @@ -6660,6 +7174,15 @@ 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)); + + ColumnOrderings get receivedAt => $composableBuilder( + column: $table.receivedAt, builder: (column) => ColumnOrderings(column)); } class $$MessagesTableAnnotationComposer @@ -6712,6 +7235,15 @@ 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); + + GeneratedColumn get receivedAt => $composableBuilder( + column: $table.receivedAt, builder: (column) => column); } class $$MessagesTableTableManager extends RootTableManager< @@ -6751,6 +7283,9 @@ 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 receivedAt = const Value.absent(), Value rowid = const Value.absent(), }) => MessagesCompanion( @@ -6768,6 +7303,9 @@ class $$MessagesTableTableManager extends RootTableManager< isSentByMe: isSentByMe, isRead: isRead, companionDeviceKey: companionDeviceKey, + hopCount: hopCount, + snr: snr, + receivedAt: receivedAt, rowid: rowid, ), createCompanionCallback: ({ @@ -6785,6 +7323,9 @@ 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 receivedAt = const Value.absent(), Value rowid = const Value.absent(), }) => MessagesCompanion.insert( @@ -6802,6 +7343,9 @@ class $$MessagesTableTableManager extends RootTableManager< isSentByMe: isSentByMe, isRead: isRead, companionDeviceKey: companionDeviceKey, + hopCount: hopCount, + snr: snr, + receivedAt: receivedAt, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -6823,6 +7367,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 +9352,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..aea2385 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,46 @@ 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 + IntColumn get receivedAt => integer().withDefault(const Constant( + 0))(); // Local device clock at receive time (ms) -- primary display time; ordering itself uses SQLite's implicit rowid. `timestamp` remains the untrusted sender-embedded value @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..e988c4a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -45,6 +45,8 @@ "settings": "Einstellungen", "about": "Über", + "reportBug": "Fehler melden", + "couldNotOpenLink": "Link konnte nicht geöffnet werden", "appSettings": "App-Einstellungen", "theme": "Design", "themeLight": "Hell", @@ -151,6 +153,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..88dae26 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -45,6 +45,8 @@ "settings": "Settings", "about": "About", + "reportBug": "Report Bug", + "couldNotOpenLink": "Could not open link", "appSettings": "App Settings", "theme": "Theme", "themeLight": "Light", @@ -151,6 +153,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..60c47bd 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -356,6 +356,18 @@ abstract class AppLocalizations { /// **'About'** String get about; + /// No description provided for @reportBug. + /// + /// In en, this message translates to: + /// **'Report Bug'** + String get reportBug; + + /// No description provided for @couldNotOpenLink. + /// + /// In en, this message translates to: + /// **'Could not open link'** + String get couldNotOpenLink; + /// No description provided for @appSettings. /// /// In en, this message translates to: @@ -884,6 +896,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..4e4b01b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -137,6 +137,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get about => 'Über'; + @override + String get reportBug => 'Fehler melden'; + + @override + String get couldNotOpenLink => 'Link konnte nicht geöffnet werden'; + @override String get appSettings => 'App-Einstellungen'; @@ -412,6 +418,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..5d68df0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -137,6 +137,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get about => 'About'; + @override + String get reportBug => 'Report Bug'; + + @override + String get couldNotOpenLink => 'Could not open link'; + @override String get appSettings => 'App Settings'; @@ -408,6 +414,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/main.dart b/lib/main.dart index e0cc0d4..12894f5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'dart:io'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:window_manager/window_manager.dart'; import 'database/database.dart'; import 'models/app_settings.dart'; @@ -46,6 +47,7 @@ import 'utils/notification_payload.dart'; import 'package:permission_handler/permission_handler.dart'; import 'widgets/deep_link_listener.dart'; import 'services/debug_log_service.dart'; +import 'services/window_state_service.dart'; // Global navigator key for deep linking final GlobalKey navigatorKey = GlobalKey(); @@ -90,6 +92,11 @@ Future _runAppStartup() async { print('🚀 TEAM Flutter starting...'); print('✅ Flutter binding initialized'); + final isDesktop = !Platform.isAndroid && !Platform.isIOS; + if (isDesktop) { + await windowManager.ensureInitialized(); + } + try { // Initialize the database print('📦 Initializing database...'); @@ -108,6 +115,12 @@ Future _runAppStartup() async { final settingsService = SettingsService(prefs); print('✅ Settings loaded'); + if (isDesktop) { + final windowStateService = WindowStateService(prefs); + await windowStateService.restoreWindowState(); + windowManager.addListener(windowStateService); + } + // Initialize notification plugin print('🔔 Initializing notifications...'); final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); @@ -329,7 +342,8 @@ Future _runAppStartup() async { /// Handle notification tap to navigate to specific chat void _handleNotificationTap( NotificationResponse details, AppDatabase database) async { - print('📬 Notification tapped: ${details.payload} action=${details.actionId}'); + print( + '📬 Notification tapped: ${details.payload} action=${details.actionId}'); // Mesh-connection "Stop" action button, or a swipe-dismiss of the persistent // mesh notification → fully stop the service (kills a stuck reconnect). @@ -376,7 +390,8 @@ void _handleNotificationTap( if (contact.isRepeater) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.directMessagesDisabledForRepeaters), + content: Text(AppLocalizations.of(context)! + .directMessagesDisabledForRepeaters), ), ); return; @@ -505,7 +520,9 @@ class TeamFlutterApp extends StatelessWidget { final appTheme = settings.settings.appTheme; final isNighttime = appTheme == AppThemeMode.nighttime; SystemChrome.setEnabledSystemUIMode( - isNighttime ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, + isNighttime + ? SystemUiMode.immersiveSticky + : SystemUiMode.edgeToEdge, ); return MaterialApp( navigatorKey: navigatorKey, @@ -530,21 +547,24 @@ class TeamFlutterApp extends StatelessWidget { ), useMaterial3: true, ), - darkTheme: isNighttime ? _nighttimeTheme() : ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.blue, - brightness: Brightness.dark, - ), - appBarTheme: const AppBarTheme( - backgroundColor: Colors.black, - foregroundColor: Colors.white, - ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - selectedItemColor: Colors.blue, - unselectedItemColor: Colors.grey, - ), - useMaterial3: true, - ), + darkTheme: isNighttime + ? _nighttimeTheme() + : ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.dark, + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + bottomNavigationBarTheme: + const BottomNavigationBarThemeData( + selectedItemColor: Colors.blue, + unselectedItemColor: Colors.grey, + ), + useMaterial3: true, + ), themeMode: switch (appTheme) { AppThemeMode.light => ThemeMode.light, AppThemeMode.dark => ThemeMode.dark, @@ -569,7 +589,6 @@ class TeamFlutterApp extends StatelessWidget { } } - ThemeData _nighttimeTheme() { final base = ColorScheme.fromSeed( seedColor: NightColors.primary, @@ -604,18 +623,18 @@ ThemeData _nighttimeTheme() { iconTheme: const IconThemeData(color: NightColors.onSurface), hintColor: NightColors.onSurfaceVariant, switchTheme: SwitchThemeData( - trackColor: WidgetStateProperty.resolveWith((states) => states - .contains(WidgetState.selected) - ? NightColors.primary - : NightColors.dimmest), - thumbColor: WidgetStateProperty.resolveWith((states) => states - .contains(WidgetState.selected) - ? NightColors.onSurface - : NightColors.dim), - trackOutlineColor: WidgetStateProperty.resolveWith((states) => states - .contains(WidgetState.selected) - ? Colors.transparent - : NightColors.dim), + trackColor: WidgetStateProperty.resolveWith((states) => + states.contains(WidgetState.selected) + ? NightColors.primary + : NightColors.dimmest), + thumbColor: WidgetStateProperty.resolveWith((states) => + states.contains(WidgetState.selected) + ? NightColors.onSurface + : NightColors.dim), + trackOutlineColor: WidgetStateProperty.resolveWith((states) => + states.contains(WidgetState.selected) + ? Colors.transparent + : NightColors.dim), ), sliderTheme: const SliderThemeData( activeTrackColor: NightColors.primary, 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..07a560b 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'; @@ -45,7 +47,7 @@ class _ChannelChatScreenState extends State { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); final FocusNode _inputFocusNode = FocusNode(); - int? _firstUnreadTimestamp; + String? _firstUnreadMessageId; List _mentionSuggestions = []; StreamSubscription>? _messagesSub; late final Stream> _messagesStream; @@ -96,15 +98,15 @@ class _ChannelChatScreenState extends State { MessageNotificationService.activeChannelHash = widget.channel.hash; // Get first unread timestamp for divider - _loadFirstUnreadTimestamp(); + _loadFirstUnreadSequence(); } - Future _loadFirstUnreadTimestamp() async { - final timestamp = await _messageRepository.messagesDao - .getFirstUnreadTimestampByChannel(widget.channel.hash); + Future _loadFirstUnreadSequence() async { + final messageId = await _messageRepository.messagesDao + .getFirstUnreadMessageIdByChannel(widget.channel.hash); if (!mounted) return; setState(() { - _firstUnreadTimestamp = timestamp; + _firstUnreadMessageId = messageId; }); // Mark as read now, while this screen is still visible, so the channel // list is already sorted correctly by the time the user navigates back. @@ -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 = _firstUnreadMessageId != null && + message.id == _firstUnreadMessageId; 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), @@ -337,12 +338,12 @@ class _ChannelChatScreenState extends State { onChanged: (text) { // Mark as read when user starts typing if (text.isNotEmpty && - _firstUnreadTimestamp != null) { + _firstUnreadMessageId != null) { _messageRepository.messagesDao .markChannelMessagesAsRead( widget.channel.hash); setState(() { - _firstUnreadTimestamp = null; + _firstUnreadMessageId = null; }); } _updateMentionSuggestions(text); @@ -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)), ); } }, @@ -486,7 +489,7 @@ class _ChannelChatScreenState extends State { Widget _buildMessageBubble(MessageData message, ThemeData theme) { final isFromMe = message.isSentByMe ?? false; - final timestamp = DateTime.fromMillisecondsSinceEpoch(message.timestamp); + final timestamp = DateTime.fromMillisecondsSinceEpoch(message.receivedAt); final senderName = isFromMe ? 'You' : (message.senderName ?? _getSenderName(message.senderId)); @@ -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.receivedAt), + ), + ); + }, + ), ], ), ), @@ -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..33c4c91 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 @@ -37,7 +39,7 @@ class _DirectMessageScreenState extends State { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); final FocusNode _inputFocusNode = FocusNode(); - int? _firstUnreadTimestamp; + String? _firstUnreadMessageId; StreamSubscription>? _messagesSub; late final Stream> _messagesStream; List _allMessages = const []; @@ -86,14 +88,14 @@ class _DirectMessageScreenState extends State { MessageNotificationService.activeContactHash = widget.contact.hash; // Get first unread timestamp for divider - _loadFirstUnreadTimestamp(); + _loadFirstUnreadSequence(); } - Future _loadFirstUnreadTimestamp() async { - final timestamp = await _messageRepository.messagesDao - .getFirstUnreadTimestampByContact(widget.contact.hash); + Future _loadFirstUnreadSequence() async { + final messageId = await _messageRepository.messagesDao + .getFirstUnreadMessageIdByContact(widget.contact.hash); setState(() { - _firstUnreadTimestamp = timestamp; + _firstUnreadMessageId = messageId; }); } @@ -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 = _firstUnreadMessageId != null && + message.id == _firstUnreadMessageId; return Column( children: [ @@ -302,11 +302,13 @@ class _DirectMessageScreenState extends State { textInputAction: TextInputAction.send, onSubmitted: (_) => _sendMessage(), onChanged: (text) { - if (text.isNotEmpty && _firstUnreadTimestamp != null) { + if (text.isNotEmpty && + _firstUnreadMessageId != null) { _messageRepository.messagesDao - .markContactMessagesAsRead(widget.contact.hash); + .markContactMessagesAsRead( + widget.contact.hash); setState(() { - _firstUnreadTimestamp = null; + _firstUnreadMessageId = null; }); } }, @@ -349,7 +351,7 @@ class _DirectMessageScreenState extends State { Widget _buildMessageBubble(MessageData message, ThemeData theme) { final isFromMe = message.isSentByMe ?? false; - final timestamp = DateTime.fromMillisecondsSinceEpoch(message.timestamp); + final timestamp = DateTime.fromMillisecondsSinceEpoch(message.receivedAt); return Padding( padding: const EdgeInsets.only(bottom: 12), @@ -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.receivedAt), + ), + ); + }, + ), ], ), ), 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/services/window_state_service.dart b/lib/services/window_state_service.dart new file mode 100644 index 0000000..6461c45 --- /dev/null +++ b/lib/services/window_state_service.dart @@ -0,0 +1,66 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:window_manager/window_manager.dart'; + +/// Remembers the desktop window's size and position across launches. +/// +/// Desktop-only, ephemeral UI state that nothing else needs to react to -- +/// deliberately kept out of the reactive AppSettings/SettingsService model. +/// +/// Note: window_manager's "finished" resize/move events +/// (onWindowResized/onWindowMoved) are macOS/Windows only, not available on +/// Linux. This uses the continuous onWindowResize/onWindowMove events with +/// its own debounce instead, so it works on Linux too. +class WindowStateService with WindowListener { + static const _keyX = 'window_x'; + static const _keyY = 'window_y'; + static const _keyWidth = 'window_width'; + static const _keyHeight = 'window_height'; + static const _debounce = Duration(milliseconds: 500); + + final SharedPreferences _prefs; + Timer? _debounceTimer; + + WindowStateService(this._prefs); + + /// Applies the saved window bounds, if any were previously recorded. + /// A first launch (nothing saved yet) leaves the platform default alone. + Future restoreWindowState() async { + final x = _prefs.getDouble(_keyX); + final y = _prefs.getDouble(_keyY); + final width = _prefs.getDouble(_keyWidth); + final height = _prefs.getDouble(_keyHeight); + if (x == null || y == null || width == null || height == null) return; + + await windowManager.setBounds(Rect.fromLTWH(x, y, width, height)); + } + + @override + void onWindowResize() => _scheduleSave(); + + @override + void onWindowMove() => _scheduleSave(); + + void _scheduleSave() { + _debounceTimer?.cancel(); + _debounceTimer = Timer(_debounce, _saveBounds); + } + + Future _saveBounds() async { + final bounds = await windowManager.getBounds(); + await _prefs.setDouble(_keyX, bounds.left); + await _prefs.setDouble(_keyY, bounds.top); + await _prefs.setDouble(_keyWidth, bounds.width); + await _prefs.setDouble(_keyHeight, bounds.height); + } + + void dispose() { + _debounceTimer?.cancel(); + windowManager.removeListener(this); + } +} 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/app_menu_button.dart b/lib/widgets/app_menu_button.dart index 3e95540..124786c 100644 --- a/lib/widgets/app_menu_button.dart +++ b/lib/widgets/app_menu_button.dart @@ -1,8 +1,11 @@ // Copyright (c) 2026 tmacinc // Licensed under CC BY-NC-SA 4.0 +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:meshcore_team/screens/connection_screen.dart'; import 'package:meshcore_team/screens/settings_screen.dart'; import '../l10n/app_localizations.dart'; @@ -15,13 +18,41 @@ class AppMenuButton extends StatelessWidget { Future _showAbout(BuildContext context) async { final info = await PackageInfo.fromPlatform(); if (!context.mounted) return; + final l10n = AppLocalizations.of(context)!; showAboutDialog( context: context, applicationName: 'MeshCore TEAM', applicationVersion: 'v${info.version}', + children: [ + TextButton.icon( + icon: const Icon(Icons.bug_report_outlined), + label: Text(l10n.reportBug), + onPressed: () => _reportBug(context, info), + ), + ], ); } + Future _reportBug(BuildContext context, PackageInfo info) async { + final platform = + '${Platform.operatingSystem} ${Platform.operatingSystemVersion}'; + final body = 'App version: ${info.version} (build ${info.buildNumber})\n' + 'Platform: $platform\n\n' + 'Describe the issue:\n'; + final uri = Uri.https('github.com', '/tmacinc/MeshCore-TEAM/issues/new', { + 'body': body, + }); + + final l10n = AppLocalizations.of(context)!; + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.couldNotOpenLink)), + ); + } + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; diff --git a/lib/widgets/chat_message_text.dart b/lib/widgets/chat_message_text.dart index 004df0b..71087b7 100644 --- a/lib/widgets/chat_message_text.dart +++ b/lib/widgets/chat_message_text.dart @@ -4,15 +4,19 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../l10n/app_localizations.dart'; import '../repositories/channel_repository.dart'; -/// Renders a chat message with tappable [#hashtag] links and styled [@mention]s. +/// Renders a chat message with tappable [#hashtag] links, tappable URLs, and +/// styled [@mention]s. /// /// Tapping a #hashtag presents a confirmation dialog that joins the channel /// whose PSK is derived from the name alone — no QR exchange needed. /// +/// Tapping a URL opens it in the device's default browser. +/// /// @mentions are highlighted in the secondary colour but are not currently /// interactive (the exact mention format may vary by firmware version). class ChatMessageText extends StatefulWidget { @@ -29,8 +33,33 @@ class ChatMessageText extends StatefulWidget { } class _ChatMessageTextState extends State { - /// Matches #hashtag (alphanumeric, underscore, hyphen) and @mention (non-whitespace). - static final _tokenPattern = RegExp(r'(#[a-zA-Z0-9_-]+|@\[[^\]]+\])'); + /// Matches #hashtag (alphanumeric, underscore, hyphen), @mention + /// (non-whitespace), and http(s)/www URLs. + static final _tokenPattern = RegExp( + r'(#[a-zA-Z0-9_-]+|@\[[^\]]+\]|https?://\S+|www\.\S+)', + ); + + /// Trailing characters trimmed off a matched URL so sentence punctuation + /// immediately after a link (e.g. "see https://example.com.") isn't + /// swallowed into the tappable span. + static const _urlTrailingPunctuation = '.,;:!?\'")]}'; + + static bool _isUrl(String token) => + token.startsWith('http://') || + token.startsWith('https://') || + token.startsWith('www.'); + + /// End offset of [match] with any trailing punctuation trimmed off, for + /// URL tokens only (other token types are returned unchanged). + int _effectiveEnd(RegExpMatch match) { + if (!_isUrl(match.group(0)!)) return match.end; + var end = match.end; + while (end > match.start && + _urlTrailingPunctuation.contains(widget.text[end - 1])) { + end--; + } + return end; + } // Recognizers and matches are built once and reused across rebuilds. // Rebuilt only in didUpdateWidget when widget.text changes, which never @@ -74,6 +103,10 @@ class _ChatMessageTextState extends State { if (token.startsWith('#')) { _recognizers.add(TapGestureRecognizer() ..onTap = () => _onHashtagTapped(context, token)); + } else if (_isUrl(token)) { + final url = widget.text.substring(match.start, _effectiveEnd(match)); + _recognizers.add( + TapGestureRecognizer()..onTap = () => _onUrlTapped(context, url)); } } } @@ -106,6 +139,23 @@ class _ChatMessageTextState extends State { ), recognizer: _recognizers[recIdx++], )); + } else if (_isUrl(token)) { + final urlEnd = _effectiveEnd(match); + spans.add(TextSpan( + text: widget.text.substring(match.start, urlEnd), + style: baseStyle?.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + ), + recognizer: _recognizers[recIdx++], + )); + if (urlEnd < match.end) { + spans.add(TextSpan( + text: widget.text.substring(urlEnd, match.end), + style: baseStyle, + )); + } } else { // @mention — visual highlight only (format TBD by firmware) spans.add(TextSpan( @@ -130,6 +180,19 @@ class _ChatMessageTextState extends State { return RichText(text: TextSpan(children: spans)); } + Future _onUrlTapped(BuildContext context, String url) async { + final uri = Uri.tryParse(url.startsWith('http') ? url : 'https://$url'); + final l10n = AppLocalizations.of(context)!; + if (uri == null || + !await launchUrl(uri, mode: LaunchMode.externalApplication)) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.couldNotOpenLink)), + ); + } + } + } + Future _onHashtagTapped(BuildContext context, String tag) async { final channelRepository = context.read(); final l10n = AppLocalizations.of(context)!; 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/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index fc949e0..8c8315c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,17 +7,25 @@ #include "generated_plugin_registrant.h" #include +#include #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index c34d078..7c3784d 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,11 +4,14 @@ list(APPEND FLUTTER_PLUGIN_LIST gtk + screen_retriever_linux sqlite3_flutter_libs url_launcher_linux + window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index be1ee9a..e8fa58b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,12 +13,14 @@ import flutter_local_notifications import geolocator_apple import mobile_scanner import package_info_plus -import path_provider_foundation +import screen_retriever_macos import share_plus import shared_preferences_foundation import sqflite_darwin import sqlite3_flutter_libs +import url_launcher_macos import wakelock_plus +import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) @@ -29,10 +31,12 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 9f0aeb7..555751f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b url: "https://pub.dev" source: hosted - version: "85.0.0" + version: "100.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "13.0.0" android_intent_plus: dependency: "direct main" description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: app_links_platform_interface - sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.4" app_links_web: dependency: transitive description: @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.1" battery_plus: dependency: "direct main" description: @@ -109,58 +109,42 @@ packages: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" build: dependency: transitive description: name: build - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + sha256: f2c223156a26eea323e6244b85141d76413a80aeee9fe0b380773789fabaf8ae url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.3.1" build_daemon: dependency: transitive description: name: build_daemon - sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" - url: "https://pub.dev" - source: hosted - version: "4.0.4" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "4.1.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" - url: "https://pub.dev" - source: hosted - version: "2.4.15" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "2.15.1" built_collection: dependency: transitive description: @@ -173,10 +157,10 @@ packages: dependency: transitive description: name: built_value - sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" url: "https://pub.dev" source: hosted - version: "8.12.3" + version: "8.12.6" cached_network_image: dependency: "direct main" description: @@ -221,18 +205,18 @@ packages: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" cli_util: dependency: transitive description: name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + sha256: "5909d2c6b66817222779e1eedc19e0e28b76d1df7bd9856a4792ccb9881df358" url: "https://pub.dev" source: hosted - version: "0.4.2" + version: "0.5.1" clock: dependency: transitive description: @@ -241,14 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - code_builder: + code_assets: dependency: transitive description: - name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.dev" source: hosted - version: "4.10.1" + version: "1.2.1" collection: dependency: transitive description: @@ -277,10 +261,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" url: "https://pub.dev" source: hosted - version: "0.3.4+2" + version: "0.3.5+4" crypto: dependency: "direct main" description: @@ -293,10 +277,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" dart_earcut: dependency: transitive description: @@ -309,34 +293,34 @@ packages: dependency: transitive description: name: dart_style - sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.9" dbus: dependency: transitive description: name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.14" drift: dependency: "direct main" description: name: drift - sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1" + sha256: "84688491040b0ceb26575709a84d701c84ad4df4d8aff020adf6d85f155fb0dd" url: "https://pub.dev" source: hosted - version: "2.28.2" + version: "2.34.2" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4" + sha256: "5c0e6ae3c2afcfc2e3f66ea51e7268622906a1e29fd0ee82e6a9b17bfd73a9b0" url: "https://pub.dev" source: hosted - version: "2.28.0" + version: "2.34.4" fake_async: dependency: transitive description: @@ -349,10 +333,10 @@ packages: dependency: transitive description: name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" file: dependency: transitive description: @@ -386,58 +370,58 @@ packages: dependency: "direct main" description: name: flutter_blue_plus - sha256: "88a65ead7dea67ddcc03e6ca846163c6b6cc09a2dcebdb8bb601fcd654ea9382" + sha256: "8b2e2f5870539e2d2a8a6473557455b990b87bc0cd86e7224bb3e20ee2eb00df" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.3.10" flutter_blue_plus_android: dependency: transitive description: name: flutter_blue_plus_android - sha256: "5010b0960cce533a8fa71401573f044362c3e2e111dc6eb4898c92e85f85f50c" + sha256: "5f1db477d442974c516196718e2ab66e3601e9a2de271bddde9368c1a85e6e64" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "9.0.3" flutter_blue_plus_darwin: dependency: transitive description: name: flutter_blue_plus_darwin - sha256: "52b155d868e17c1c8ad85520a0912d447d92aedccb5a5e234d3edc98ebd1307a" + sha256: bf41a4a07978b4c86a344c0c6e7388ff69f2b442a8d943b8da6d7f01a5c435bc url: "https://pub.dev" source: hosted - version: "8.1.1" + version: "9.0.3" flutter_blue_plus_linux: dependency: transitive description: name: flutter_blue_plus_linux - sha256: f5b02244d89465ba82c8c512686c66362fbb01f52fa03d645ed353ebf3883242 + sha256: "79387947c27d04fce505916d168a1f8b7a89846d22d11a659970aba316459622" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "9.0.3" flutter_blue_plus_platform_interface: dependency: transitive description: name: flutter_blue_plus_platform_interface - sha256: "6e0fc04b77491dbfdbcd46c1a021b12f2f5fc5d6e01777f93a38a8431989b7f0" + sha256: "9378ed463673ab51e7ab72cf4bad3633b134182ca184ddcc598d6f7474ada993" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "9.0.3" flutter_blue_plus_web: dependency: transitive description: name: flutter_blue_plus_web - sha256: "376aad9595ee389c7cd56e0c373e78abcaa790c821ece9cb81f0969ec94c5bca" + sha256: "62670fd0072e9424661170c3439eb2784679e0cb8420907ae2fe979aab8eed71" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "9.0.3" flutter_blue_plus_winrt: dependency: transitive description: name: flutter_blue_plus_winrt - sha256: ed894f0ab341f4cece8fa33edc381d46424a7c5bfd0e841d933d0f8c34c86521 + sha256: "0000b2d818e6f79ad07764206fdd8afb69426fd44453c6254c35828fd16aa09f" url: "https://pub.dev" source: hosted - version: "0.0.18" + version: "0.0.20" flutter_cache_manager: dependency: "direct main" description: @@ -450,10 +434,10 @@ packages: dependency: "direct main" description: name: flutter_file_dialog - sha256: ec904d15e7da3691bb60442a762b0a09afa37ded7265b9fc2088ec202b7d844f + sha256: a8adb4411bf53835e6176481b12c3dc79abc9b61d9c90198a2eddddcb8cf71c7 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.3.1" flutter_foreground_task: dependency: "direct main" description: @@ -511,10 +495,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "6382ce712ff69b0f719640ce957559dde459e55ecd433c767e06d139ddf16cab" + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.dev" source: hosted - version: "2.0.29" + version: "2.0.35" flutter_test: dependency: "direct dev" description: flutter @@ -525,14 +509,6 @@ packages: description: flutter source: sdk version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" geolocator: dependency: "direct main" description: @@ -553,26 +529,26 @@ packages: dependency: transitive description: name: geolocator_apple - sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" url: "https://pub.dev" source: hosted - version: "2.3.13" + version: "2.3.14" geolocator_platform_interface: dependency: transitive description: name: geolocator_platform_interface - sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 url: "https://pub.dev" source: hosted - version: "4.2.6" + version: "4.2.8" geolocator_web: dependency: transitive description: name: geolocator_web - sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "4.1.4" geolocator_windows: dependency: transitive description: @@ -609,10 +585,18 @@ packages: dependency: transitive description: name: gtk - sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" http: dependency: "direct main" description: @@ -653,22 +637,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: dependency: transitive description: - name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "1.0.1" json_annotation: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.12.0" latlong2: dependency: "direct main" description: @@ -721,10 +713,10 @@ packages: dependency: transitive description: name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.7.0" logging: dependency: transitive description: @@ -737,10 +729,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 +745,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: @@ -781,6 +773,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.11" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" nested: dependency: transitive description: @@ -789,6 +789,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" octo_image: dependency: transitive description: @@ -833,42 +841,42 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -897,10 +905,10 @@ packages: dependency: transitive description: name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" url: "https://pub.dev" source: hosted - version: "9.4.7" + version: "9.4.10" permission_handler_html: dependency: transitive description: @@ -929,10 +937,10 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "7.0.2" platform: dependency: transitive description: @@ -949,6 +957,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: @@ -1037,6 +1053,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" rxdart: dependency: "direct main" description: @@ -1045,14 +1069,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" share_plus: dependency: "direct main" description: name: share_plus - sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840" + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" url: "https://pub.dev" source: hosted - version: "12.0.1" + version: "12.0.2" share_plus_platform_interface: dependency: transitive description: @@ -1065,26 +1129,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.4.27" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -1097,10 +1161,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -1142,82 +1206,82 @@ packages: dependency: transitive description: name: source_gen - sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "4.2.3" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.2" sqflite: dependency: transitive description: name: sqflite - sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" sqflite_android: dependency: transitive description: name: sqflite_android - sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.3" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" url: "https://pub.dev" source: hosted - version: "2.5.4+6" + version: "2.5.11" sqflite_darwin: dependency: transitive description: name: sqflite_darwin - sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f url: "https://pub.dev" source: hosted - version: "2.4.1+1" + version: "2.4.3+1" sqflite_platform_interface: dependency: transitive description: name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" sqlite3: dependency: transitive description: name: sqlite3 - sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + sha256: c73fd75df1332d76a6257f4823ae4df9c791f522b97e4a60cbcad214de1becf4 url: "https://pub.dev" source: hosted - version: "2.9.4" + version: "3.5.0" sqlite3_flutter_libs: dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: "1e800ebe7f85a80a66adacaa6febe4d5f4d8b75f244e9838a27cb2ffc7aec08d" + sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad url: "https://pub.dev" source: hosted - version: "0.5.41" + version: "0.5.42" sqlparser: dependency: transitive description: name: sqlparser - sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67" + sha256: "772bb2f6f5bce0631a60f26b57d6e8882e1105c22345b05c163ee6ee5d7ba32d" url: "https://pub.dev" source: hosted - version: "0.41.2" + version: "0.45.0" stack_trace: dependency: transitive description: @@ -1246,34 +1310,34 @@ packages: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" synchronized: dependency: transitive description: name: synchronized - sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" url: "https://pub.dev" source: hosted - version: "3.3.0+3" + version: "3.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: 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: @@ -1282,14 +1346,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.10.1" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" typed_data: dependency: transitive description: @@ -1314,14 +1370,46 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -1334,26 +1422,26 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" uuid: dependency: "direct main" description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.6.0" vector_math: dependency: transitive description: @@ -1366,10 +1454,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "14.3.0" + version: "15.2.0" wakelock_plus: dependency: "direct main" description: @@ -1382,10 +1470,10 @@ packages: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.5.1" watcher: dependency: transitive description: @@ -1422,10 +1510,18 @@ packages: dependency: transitive description: name: win32 - sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.10.1" + version: "5.15.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" wkt_parser: dependency: transitive description: @@ -1446,10 +1542,10 @@ packages: dependency: "direct main" description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: @@ -1459,5 +1555,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1566550..eb16c84 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,9 @@ dependencies: # Utilities crypto: ^3.0.3 + pointycastle: ^4.0.0 + url_launcher: ^6.3.2 + window_manager: ^0.5.2 http: ^1.2.0 package_info_plus: ^8.0.0 battery_plus: ^6.2.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index d806310..4e7fc37 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -11,9 +11,11 @@ #include #include #include +#include #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( @@ -26,10 +28,14 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("GeolocatorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0807f9a..e74dec9 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -8,12 +8,15 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_blue_plus_winrt geolocator_windows permission_handler_windows + screen_retriever_windows share_plus sqlite3_flutter_libs url_launcher_windows + window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)