From d5bba82070cee9b843bb408e4123f61cb959c1d7 Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sat, 18 Jul 2026 16:00:36 -0700 Subject: [PATCH 1/9] Issue 52: make URLs in chat messages tappable Extends the existing #hashtag/@mention token parser in ChatMessageText to detect http(s):// and www. URLs, trimming trailing sentence punctuation from the tappable span. Tapping opens the link via url_launcher in the external browser. --- android/app/src/main/AndroidManifest.xml | 7 ++ lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 ++ lib/l10n/app_localizations_de.dart | 3 + lib/l10n/app_localizations_en.dart | 3 + lib/widgets/chat_message_text.dart | 69 ++++++++++++++++++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 48 ++++++++++--- pubspec.yaml | 1 + 10 files changed, 130 insertions(+), 11 deletions(-) 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/l10n/app_de.arb b/lib/l10n/app_de.arb index 9393f36..b983396 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -312,6 +312,7 @@ "linkCopied": "Link kopiert", "copied": "Kopiert", + "couldNotOpenLink": "Link konnte nicht geöffnet werden", "debugLogsTitleWithCount": "Debug-Protokolle ({count})", "@debugLogsTitleWithCount": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a6a6e11..229cbcd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -312,6 +312,7 @@ "linkCopied": "Link copied", "copied": "Copied", + "couldNotOpenLink": "Could not open link", "debugLogsTitleWithCount": "Debug Logs ({count})", "@debugLogsTitleWithCount": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 855eec9..c9bf7ba 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1442,6 +1442,12 @@ abstract class AppLocalizations { /// **'Copied'** String get copied; + /// No description provided for @couldNotOpenLink. + /// + /// In en, this message translates to: + /// **'Could not open link'** + String get couldNotOpenLink; + /// No description provided for @debugLogsTitleWithCount. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ed82870..5dd3ddc 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -718,6 +718,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get copied => 'Kopiert'; + @override + String get couldNotOpenLink => 'Link konnte nicht geöffnet werden'; + @override String debugLogsTitleWithCount(int count) { return 'Debug-Protokolle ($count)'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index efd3b48..0080d22 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -712,6 +712,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get copied => 'Copied'; + @override + String get couldNotOpenLink => 'Could not open link'; + @override String debugLogsTitleWithCount(int count) { return 'Debug Logs ($count)'; 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/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index be1ee9a..4f2ea53 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -18,6 +18,7 @@ import share_plus import shared_preferences_foundation import sqflite_darwin import sqlite3_flutter_libs +import url_launcher_macos import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -34,5 +35,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 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")) } diff --git a/pubspec.lock b/pubspec.lock index 9f0aeb7..75cefa0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -737,10 +737,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -753,10 +753,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1270,10 +1270,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" timezone: dependency: transitive description: @@ -1314,6 +1314,30 @@ 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: @@ -1322,6 +1346,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + 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: @@ -1459,5 +1491,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..f302a0d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,6 +67,7 @@ dependencies: http: ^1.2.0 package_info_plus: ^8.0.0 battery_plus: ^6.2.0 + url_launcher: ^6.3.2 # Map tile caching / offline maps cached_network_image: ^3.4.1 From e0586a35090ed6194a4e2645c179dc628c9ab0d2 Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sat, 18 Jul 2026 16:46:00 -0700 Subject: [PATCH 2/9] Issue 65: show per-message hop count in chat Persists path length and SNR from the existing V3 message-receive responses (previously parsed then discarded) onto each Messages row. Adds a (d)/(N) badge next to the sender name and a long-press "Message path" sheet showing a simple sender -> hop count -> you timeline. Named per-hop routing and multi-path duplicate detection (richer data from the raw PUSH_LOG_RX_DATA frame) are deferred -- correlating that frame to a specific decoded message isn't safe with the current sync loop, which can drain multiple queued messages per push. --- lib/database/database.dart | 12 +- lib/database/database.g.dart | 119 ++++++++++++++- lib/database/tables.dart | 10 +- lib/l10n/app_de.arb | 8 + lib/l10n/app_en.arb | 8 + lib/l10n/app_localizations.dart | 18 +++ lib/l10n/app_localizations_de.dart | 17 +++ lib/l10n/app_localizations_en.dart | 17 +++ lib/repositories/message_repository.dart | 4 + lib/screens/channel_chat_screen.dart | 180 ++++++++++++++--------- lib/screens/direct_message_screen.dart | 153 +++++++++++-------- lib/widgets/message_path_sheet.dart | 113 ++++++++++++++ 12 files changed, 518 insertions(+), 141 deletions(-) create mode 100644 lib/widgets/message_path_sheet.dart diff --git a/lib/database/database.dart b/lib/database/database.dart index 3de9b72..2d22998 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -70,7 +70,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 +161,15 @@ 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: Add per-message hop count and SNR + if (from <= 8 && to >= 9) { + await m.addColumn(messages, messages.hopCount); + await m.addColumn(messages, messages.snr); + print('[Migration] v8->v9: added hopCount, snr to messages table'); } }, ); diff --git a/lib/database/database.g.dart b/lib/database/database.g.dart index 056f521..5db282a 100644 --- a/lib/database/database.g.dart +++ b/lib/database/database.g.dart @@ -1547,6 +1547,17 @@ class $MessagesTable extends Messages late final GeneratedColumn companionDeviceKey = GeneratedColumn('companion_device_key', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _hopCountMeta = + const VerificationMeta('hopCount'); + @override + late final GeneratedColumn hopCount = GeneratedColumn( + 'hop_count', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _snrMeta = const VerificationMeta('snr'); + @override + late final GeneratedColumn snr = GeneratedColumn( + 'snr', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); @override List get $columns => [ id, @@ -1562,7 +1573,9 @@ class $MessagesTable extends Messages attempt, isSentByMe, isRead, - companionDeviceKey + companionDeviceKey, + hopCount, + snr ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1659,6 +1672,14 @@ class $MessagesTable extends Messages companionDeviceKey.isAcceptableOrUnknown( data['companion_device_key']!, _companionDeviceKeyMeta)); } + if (data.containsKey('hop_count')) { + context.handle(_hopCountMeta, + hopCount.isAcceptableOrUnknown(data['hop_count']!, _hopCountMeta)); + } + if (data.containsKey('snr')) { + context.handle( + _snrMeta, snr.isAcceptableOrUnknown(data['snr']!, _snrMeta)); + } return context; } @@ -1696,6 +1717,10 @@ class $MessagesTable extends Messages .read(DriftSqlType.bool, data['${effectivePrefix}is_read'])!, companionDeviceKey: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}companion_device_key']), + hopCount: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}hop_count']), + snr: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}snr']), ); } @@ -1720,6 +1745,8 @@ class MessageData extends DataClass implements Insertable { final bool isSentByMe; final bool isRead; final String? companionDeviceKey; + final int? hopCount; + final int? snr; const MessageData( {required this.id, required this.senderId, @@ -1734,7 +1761,9 @@ class MessageData extends DataClass implements Insertable { required this.attempt, required this.isSentByMe, required this.isRead, - this.companionDeviceKey}); + this.companionDeviceKey, + this.hopCount, + this.snr}); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -1758,6 +1787,12 @@ class MessageData extends DataClass implements Insertable { if (!nullToAbsent || companionDeviceKey != null) { map['companion_device_key'] = Variable(companionDeviceKey); } + if (!nullToAbsent || hopCount != null) { + map['hop_count'] = Variable(hopCount); + } + if (!nullToAbsent || snr != null) { + map['snr'] = Variable(snr); + } return map; } @@ -1783,6 +1818,10 @@ class MessageData extends DataClass implements Insertable { companionDeviceKey: companionDeviceKey == null && nullToAbsent ? const Value.absent() : Value(companionDeviceKey), + hopCount: hopCount == null && nullToAbsent + ? const Value.absent() + : Value(hopCount), + snr: snr == null && nullToAbsent ? const Value.absent() : Value(snr), ); } @@ -1805,6 +1844,8 @@ class MessageData extends DataClass implements Insertable { isRead: serializer.fromJson(json['isRead']), companionDeviceKey: serializer.fromJson(json['companionDeviceKey']), + hopCount: serializer.fromJson(json['hopCount']), + snr: serializer.fromJson(json['snr']), ); } @override @@ -1825,6 +1866,8 @@ class MessageData extends DataClass implements Insertable { 'isSentByMe': serializer.toJson(isSentByMe), 'isRead': serializer.toJson(isRead), 'companionDeviceKey': serializer.toJson(companionDeviceKey), + 'hopCount': serializer.toJson(hopCount), + 'snr': serializer.toJson(snr), }; } @@ -1842,7 +1885,9 @@ class MessageData extends DataClass implements Insertable { int? attempt, bool? isSentByMe, bool? isRead, - Value companionDeviceKey = const Value.absent()}) => + Value companionDeviceKey = const Value.absent(), + Value hopCount = const Value.absent(), + Value snr = const Value.absent()}) => MessageData( id: id ?? this.id, senderId: senderId ?? this.senderId, @@ -1860,6 +1905,8 @@ class MessageData extends DataClass implements Insertable { companionDeviceKey: companionDeviceKey.present ? companionDeviceKey.value : this.companionDeviceKey, + hopCount: hopCount.present ? hopCount.value : this.hopCount, + snr: snr.present ? snr.value : this.snr, ); MessageData copyWithCompanion(MessagesCompanion data) { return MessageData( @@ -1887,6 +1934,8 @@ class MessageData extends DataClass implements Insertable { companionDeviceKey: data.companionDeviceKey.present ? data.companionDeviceKey.value : this.companionDeviceKey, + hopCount: data.hopCount.present ? data.hopCount.value : this.hopCount, + snr: data.snr.present ? data.snr.value : this.snr, ); } @@ -1906,7 +1955,9 @@ class MessageData extends DataClass implements Insertable { ..write('attempt: $attempt, ') ..write('isSentByMe: $isSentByMe, ') ..write('isRead: $isRead, ') - ..write('companionDeviceKey: $companionDeviceKey') + ..write('companionDeviceKey: $companionDeviceKey, ') + ..write('hopCount: $hopCount, ') + ..write('snr: $snr') ..write(')')) .toString(); } @@ -1926,7 +1977,9 @@ class MessageData extends DataClass implements Insertable { attempt, isSentByMe, isRead, - companionDeviceKey); + companionDeviceKey, + hopCount, + snr); @override bool operator ==(Object other) => identical(this, other) || @@ -1944,7 +1997,9 @@ class MessageData extends DataClass implements Insertable { other.attempt == this.attempt && other.isSentByMe == this.isSentByMe && other.isRead == this.isRead && - other.companionDeviceKey == this.companionDeviceKey); + other.companionDeviceKey == this.companionDeviceKey && + other.hopCount == this.hopCount && + other.snr == this.snr); } class MessagesCompanion extends UpdateCompanion { @@ -1962,6 +2017,8 @@ class MessagesCompanion extends UpdateCompanion { final Value isSentByMe; final Value isRead; final Value companionDeviceKey; + final Value hopCount; + final Value snr; final Value rowid; const MessagesCompanion({ this.id = const Value.absent(), @@ -1978,6 +2035,8 @@ class MessagesCompanion extends UpdateCompanion { this.isSentByMe = const Value.absent(), this.isRead = const Value.absent(), this.companionDeviceKey = const Value.absent(), + this.hopCount = const Value.absent(), + this.snr = const Value.absent(), this.rowid = const Value.absent(), }); MessagesCompanion.insert({ @@ -1995,6 +2054,8 @@ class MessagesCompanion extends UpdateCompanion { required bool isSentByMe, this.isRead = const Value.absent(), this.companionDeviceKey = const Value.absent(), + this.hopCount = const Value.absent(), + this.snr = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), senderId = Value(senderId), @@ -2019,6 +2080,8 @@ class MessagesCompanion extends UpdateCompanion { Expression? isSentByMe, Expression? isRead, Expression? companionDeviceKey, + Expression? hopCount, + Expression? snr, Expression? rowid, }) { return RawValuesInsertable({ @@ -2037,6 +2100,8 @@ class MessagesCompanion extends UpdateCompanion { if (isRead != null) 'is_read': isRead, if (companionDeviceKey != null) 'companion_device_key': companionDeviceKey, + if (hopCount != null) 'hop_count': hopCount, + if (snr != null) 'snr': snr, if (rowid != null) 'rowid': rowid, }); } @@ -2056,6 +2121,8 @@ class MessagesCompanion extends UpdateCompanion { Value? isSentByMe, Value? isRead, Value? companionDeviceKey, + Value? hopCount, + Value? snr, Value? rowid}) { return MessagesCompanion( id: id ?? this.id, @@ -2072,6 +2139,8 @@ class MessagesCompanion extends UpdateCompanion { isSentByMe: isSentByMe ?? this.isSentByMe, isRead: isRead ?? this.isRead, companionDeviceKey: companionDeviceKey ?? this.companionDeviceKey, + hopCount: hopCount ?? this.hopCount, + snr: snr ?? this.snr, rowid: rowid ?? this.rowid, ); } @@ -2121,6 +2190,12 @@ class MessagesCompanion extends UpdateCompanion { if (companionDeviceKey.present) { map['companion_device_key'] = Variable(companionDeviceKey.value); } + if (hopCount.present) { + map['hop_count'] = Variable(hopCount.value); + } + if (snr.present) { + map['snr'] = Variable(snr.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -2144,6 +2219,8 @@ class MessagesCompanion extends UpdateCompanion { ..write('isSentByMe: $isSentByMe, ') ..write('isRead: $isRead, ') ..write('companionDeviceKey: $companionDeviceKey, ') + ..write('hopCount: $hopCount, ') + ..write('snr: $snr, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -6533,6 +6610,8 @@ typedef $$MessagesTableCreateCompanionBuilder = MessagesCompanion Function({ required bool isSentByMe, Value isRead, Value companionDeviceKey, + Value hopCount, + Value snr, Value rowid, }); typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ @@ -6550,6 +6629,8 @@ typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ Value isSentByMe, Value isRead, Value companionDeviceKey, + Value hopCount, + Value snr, Value rowid, }); @@ -6605,6 +6686,12 @@ class $$MessagesTableFilterComposer ColumnFilters get companionDeviceKey => $composableBuilder( column: $table.companionDeviceKey, builder: (column) => ColumnFilters(column)); + + ColumnFilters get hopCount => $composableBuilder( + column: $table.hopCount, builder: (column) => ColumnFilters(column)); + + ColumnFilters get snr => $composableBuilder( + column: $table.snr, builder: (column) => ColumnFilters(column)); } class $$MessagesTableOrderingComposer @@ -6660,6 +6747,12 @@ class $$MessagesTableOrderingComposer ColumnOrderings get companionDeviceKey => $composableBuilder( column: $table.companionDeviceKey, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get hopCount => $composableBuilder( + column: $table.hopCount, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get snr => $composableBuilder( + column: $table.snr, builder: (column) => ColumnOrderings(column)); } class $$MessagesTableAnnotationComposer @@ -6712,6 +6805,12 @@ class $$MessagesTableAnnotationComposer GeneratedColumn get companionDeviceKey => $composableBuilder( column: $table.companionDeviceKey, builder: (column) => column); + + GeneratedColumn get hopCount => + $composableBuilder(column: $table.hopCount, builder: (column) => column); + + GeneratedColumn get snr => + $composableBuilder(column: $table.snr, builder: (column) => column); } class $$MessagesTableTableManager extends RootTableManager< @@ -6751,6 +6850,8 @@ class $$MessagesTableTableManager extends RootTableManager< Value isSentByMe = const Value.absent(), Value isRead = const Value.absent(), Value companionDeviceKey = const Value.absent(), + Value hopCount = const Value.absent(), + Value snr = const Value.absent(), Value rowid = const Value.absent(), }) => MessagesCompanion( @@ -6768,6 +6869,8 @@ class $$MessagesTableTableManager extends RootTableManager< isSentByMe: isSentByMe, isRead: isRead, companionDeviceKey: companionDeviceKey, + hopCount: hopCount, + snr: snr, rowid: rowid, ), createCompanionCallback: ({ @@ -6785,6 +6888,8 @@ class $$MessagesTableTableManager extends RootTableManager< required bool isSentByMe, Value isRead = const Value.absent(), Value companionDeviceKey = const Value.absent(), + Value hopCount = const Value.absent(), + Value snr = const Value.absent(), Value rowid = const Value.absent(), }) => MessagesCompanion.insert( @@ -6802,6 +6907,8 @@ class $$MessagesTableTableManager extends RootTableManager< isSentByMe: isSentByMe, isRead: isRead, companionDeviceKey: companionDeviceKey, + hopCount: hopCount, + snr: snr, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/database/tables.dart b/lib/database/tables.dart index 9dcf2c6..87c5983 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,6 +89,10 @@ class Messages extends Table { BoolColumn get isRead => boolean().withDefault(const Constant(false))(); // Message read status TextColumn get companionDeviceKey => text().nullable()(); + IntColumn get hopCount => integer() + .nullable()(); // Relay hops for this specific message (null = unknown, 0 = direct) + IntColumn get snr => + integer().nullable()(); // Signal-to-noise ratio for this message @override Set get primaryKey => {id}; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9393f36..94eff95 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -151,6 +151,14 @@ "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" } + } + }, "notificationsMuted": "🔕 Stumm", "notificationsSilent": "🔕 Lautlos", "channelNotifications": "Kanal-Benachrichtigungen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a6a6e11..b1d7b03 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -151,6 +151,14 @@ "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" } + } + }, "notificationsMuted": "🔕 Muted", "notificationsSilent": "🔕 Silent", "channelNotifications": "Channel notifications", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 855eec9..ae2bec4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -884,6 +884,24 @@ 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 @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..2d7615e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -412,6 +412,23 @@ 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 get notificationsMuted => '🔕 Stumm'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index efd3b48..da23c40 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -408,6 +408,23 @@ 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 get notificationsMuted => '🔕 Muted'; diff --git a/lib/repositories/message_repository.dart b/lib/repositories/message_repository.dart index e7a238c..ce6392a 100644 --- a/lib/repositories/message_repository.dart +++ b/lib/repositories/message_repository.dart @@ -666,6 +666,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 +833,8 @@ class MessageRepository { deliveryStatus: 'RECEIVED', companionDeviceKey: drift.Value(_settingsService.settings.currentCompanionPublicKey), + hopCount: drift.Value(response.pathLength), + snr: drift.Value(response.snr), ); // Insert message diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 8f47385..440cafa 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -20,6 +20,7 @@ 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_path_sheet.dart'; import '../widgets/status_bar_actions.dart'; import '../models/app_settings.dart'; import '../services/settings_service.dart'; @@ -140,7 +141,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 +227,9 @@ class _ChannelChatScreenState extends State { padding: const EdgeInsets.all(16), itemCount: _messages.length, itemBuilder: (context, index) { - final message = - _messages[_messages.length - 1 - index]; - final showUnreadDivider = - _firstUnreadTimestamp != null && - message.timestamp == _firstUnreadTimestamp; + final message = _messages[_messages.length - 1 - index]; + final showUnreadDivider = _firstUnreadTimestamp != null && + message.timestamp == _firstUnreadTimestamp; return Column( children: [ @@ -265,8 +266,7 @@ class _ChannelChatScreenState extends State { Text( '$_newMessageCount new ${_newMessageCount == 1 ? 'message' : 'messages'}', style: theme.textTheme.labelMedium?.copyWith( - color: - theme.colorScheme.onPrimaryContainer, + color: theme.colorScheme.onPrimaryContainer, ), ), const SizedBox(width: 4), @@ -432,7 +432,9 @@ class _ChannelChatScreenState extends State { await Clipboard.setData(ClipboardData(text: link)); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.linkCopied)), + SnackBar( + content: + Text(AppLocalizations.of(context)!.linkCopied)), ); } }, @@ -507,71 +509,89 @@ 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), + Text( + message.hopCount == 0 + ? '(d)' + : '(${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,23 @@ 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( + senderName: senderName, + hopCount: message.hopCount!, + timestamp: DateTime.fromMillisecondsSinceEpoch( + message.timestamp), + ), + ); + }, + ), ], ), ), @@ -761,7 +799,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 +842,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..8eabb63 100644 --- a/lib/screens/direct_message_screen.dart +++ b/lib/screens/direct_message_screen.dart @@ -17,6 +17,7 @@ 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_path_sheet.dart'; import '../widgets/status_bar_actions.dart'; /// Direct message chat screen for one-on-one conversations @@ -200,11 +201,9 @@ class _DirectMessageScreenState extends State { padding: const EdgeInsets.all(16), itemCount: _messages.length, itemBuilder: (context, index) { - final message = - _messages[_messages.length - 1 - index]; - final showUnreadDivider = - _firstUnreadTimestamp != null && - message.timestamp == _firstUnreadTimestamp; + final message = _messages[_messages.length - 1 - index]; + final showUnreadDivider = _firstUnreadTimestamp != null && + message.timestamp == _firstUnreadTimestamp; return Column( children: [ @@ -302,9 +301,11 @@ class _DirectMessageScreenState extends State { textInputAction: TextInputAction.send, onSubmitted: (_) => _sendMessage(), onChanged: (text) { - if (text.isNotEmpty && _firstUnreadTimestamp != null) { + if (text.isNotEmpty && + _firstUnreadTimestamp != null) { _messageRepository.messagesDao - .markContactMessagesAsRead(widget.contact.hash); + .markContactMessagesAsRead( + widget.contact.hash); setState(() { _firstUnreadTimestamp = null; }); @@ -361,67 +362,80 @@ 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) ...[ + Text( + message.hopCount == 0 + ? '(d)' + : '(${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,23 @@ 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( + senderName: widget.contact.name ?? 'Unknown Contact', + hopCount: message.hopCount!, + timestamp: DateTime.fromMillisecondsSinceEpoch( + message.timestamp), + ), + ); + }, + ), ], ), ), diff --git a/lib/widgets/message_path_sheet.dart b/lib/widgets/message_path_sheet.dart new file mode 100644 index 0000000..9db0858 --- /dev/null +++ b/lib/widgets/message_path_sheet.dart @@ -0,0 +1,113 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 + +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; +import '../utils/message_time_format.dart'; + +/// Bottom sheet showing a simple sender → receiver timeline for a message, +/// with the hop count (direct or N relays) between them. +/// +/// This is a summary view only — the underlying BLE protocol doesn't +/// currently expose which specific repeaters relayed a message, just a hop +/// count, so unlike richer reference clients this can't name individual +/// hops or show per-hop signal stats yet. +class MessagePathSheet extends StatelessWidget { + final String senderName; + + /// 0 = direct, >0 = number of relay hops. + final int hopCount; + final DateTime timestamp; + + const MessagePathSheet({ + super.key, + required this.senderName, + required this.hopCount, + required this.timestamp, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + final isDirect = hopCount == 0; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.messagePath, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text( + formatMessageTime(timestamp), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + _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), + ], + ), + ), + ); + } +} + +class _TimelineNode extends StatelessWidget { + final String label; + final Color color; + + const _TimelineNode({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + CircleAvatar(radius: 4, backgroundColor: color), + const SizedBox(width: 12), + 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), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} From 2944aaaeaebcb0aa02ddf75ca98d443ddeb4760a Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sat, 18 Jul 2026 18:39:39 -0700 Subject: [PATCH 3/9] Issue 65: correlate raw radio packets for multi-path routing data Adds a message_paths table (one row per distinct radio path a channel message was observed on) populated by correlating raw PUSH_LOG_RX_DATA frames -- previously received but entirely discarded -- against already-decoded channel messages. Correlation works by re-encrypting a message's known plaintext (using its channel's PSK) and finding an exact ciphertext match against buffered raw frames, brute-forcing the 4 possible `attempt` retry values since firmware's decoded response doesn't expose that field. An exact match is unambiguous; no heuristics. Frames sharing the matched packet's hash (payload type + ciphertext, independent of path) are its multi-path siblings -- the same logical packet heard via another route (e.g. direct and relayed at once). Verified end-to-end against live traffic: correctly captured both a direct reception and a relayed one (via a specific repeater hop) for the same message, with real per-hop SNR/RSSI. A DB-level unique constraint on (messageId, pathBytes) makes duplicate-delivery correlation (the same message arriving via push then sync, an existing pattern elsewhere in this file) safe without an application-side race. Also adds getContactsByPublicKeyPrefix (all-matches variant, for later ambiguity display) alongside the existing first-match lookup. UI display of this data is a follow-up; the badge/sheet from the prior commit still uses the single-path Messages.hopCount/snr summary and is unaffected if correlation never matches. --- lib/ble/ble_responses.dart | 9 +- lib/ble/raw_packet_log.dart | 174 +++++++ lib/database/daos/contacts_dao.dart | 46 +- lib/database/daos/message_paths_dao.dart | 46 ++ lib/database/daos/message_paths_dao.g.dart | 8 + lib/database/database.dart | 10 +- lib/database/database.g.dart | 580 +++++++++++++++++++++ lib/database/tables.dart | 29 ++ lib/repositories/message_repository.dart | 204 +++++++- lib/services/channel_crypto.dart | 55 ++ pubspec.lock | 22 +- pubspec.yaml | 1 + 12 files changed, 1170 insertions(+), 14 deletions(-) create mode 100644 lib/ble/raw_packet_log.dart create mode 100644 lib/database/daos/message_paths_dao.dart create mode 100644 lib/database/daos/message_paths_dao.g.dart create mode 100644 lib/services/channel_crypto.dart diff --git a/lib/ble/ble_responses.dart b/lib/ble/ble_responses.dart index 6c1abab..02d2ff9 100644 --- a/lib/ble/ble_responses.dart +++ b/lib/ble/ble_responses.dart @@ -156,6 +156,11 @@ class ChannelMessageReceivedResponse extends BleResponse { final String text; final int snr; final int pathLength; + + /// Wire txt_type from the V3 response -- also packed into the original + /// over-the-air ciphertext's plaintext header, needed to reconstruct that + /// plaintext exactly for raw-frame correlation (see channel_crypto.dart). + final int txtType; final bool isFromSelf; ChannelMessageReceivedResponse({ @@ -166,6 +171,7 @@ class ChannelMessageReceivedResponse extends BleResponse { required this.text, required this.snr, required this.pathLength, + required this.txtType, required this.isFromSelf, }) : super(BleConstants.respChannelMsgRecvV3); } @@ -529,7 +535,7 @@ class BleResponseParser { reader.readByte(); // reserved2 final channelIndex = reader.readByte(); final pathLength = reader.readByte() & 0x3F; // bits 5–0 are hop count - final txtType = reader.readByte(); // txt_type (ignored for now) + final txtType = reader.readByte(); final timestamp = reader.readUInt32LE(); // Read remaining bytes as text @@ -543,6 +549,7 @@ class BleResponseParser { text: text, snr: snr, pathLength: pathLength, + txtType: txtType, isFromSelf: false, // V3 doesn't indicate this ); } diff --git a/lib/ble/raw_packet_log.dart b/lib/ble/raw_packet_log.dart new file mode 100644 index 0000000..109c2f6 --- /dev/null +++ b/lib/ble/raw_packet_log.dart @@ -0,0 +1,174 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 + +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart' as crypto; + +/// A parsed raw radio packet, sourced from a PUSH_LOG_RX_DATA (0x88) BLE +/// frame: `[0x88][snr int8][rssi int8][raw mesh packet]`. The raw packet +/// structure itself matches MeshCore's over-the-air format (see +/// docs/packet_format.md in the firmware repo): +/// `[header][transport_codes?][path_length][path][payload]`. +/// +/// This is the data firmware's own decoded message responses (V3) never +/// expose: the actual per-hop path bytes and true RSSI, plus one entry per +/// physical reception rather than one per logical (deduped) message. +class RawPacketFrame { + /// Raw int8 SNR value scaled by 4 (matches Messages.snr's encoding from + /// the V3 message responses -- same firmware-side scaling, no conversion + /// needed to compare the two). + final int snr; + + /// Raw RSSI in dBm. + final int rssi; + + final int routeType; + final int payloadType; + final int payloadVersion; + + /// Raw wire path_length byte (hash-size mode packed with hop count). + final int pathByte; + + /// Raw hop-hash bytes, hop_count * hash_size long. + final Uint8List pathBytes; + + /// Remaining packet bytes after path -- ciphertext for TXT_MSG/GRP_TXT. + final Uint8List payload; + + /// SHA-256(payload_type byte + payload bytes)[:16 hex, uppercase] -- + /// matches MeshCore firmware's Packet::calculatePacketHash(). Identical + /// across flood-relayed copies of the same logical packet regardless of + /// path, since it excludes routing/path bytes entirely. + final String packetHash; + + final int receivedAtMs; + + RawPacketFrame({ + required this.snr, + required this.rssi, + required this.routeType, + required this.payloadType, + required this.payloadVersion, + required this.pathByte, + required this.pathBytes, + required this.payload, + required this.packetHash, + required this.receivedAtMs, + }); +} + +/// Payload type of a text/channel-text packet, per docs/packet_format.md. +const int payloadTypeTxtMsg = 0x02; +const int payloadTypeGrpTxt = 0x05; + +/// Parses a PUSH_LOG_RX_DATA (0x88) BLE frame into a [RawPacketFrame]. +/// Returns null for malformed/too-short frames. +RawPacketFrame? parseRawPacketLogFrame(Uint8List frame, {DateTime? now}) { + // frame[0] is the 0x88 push code itself (caller strips or includes it -- + // this function expects it included, matching the raw BLE payload). + // Minimum viable length: code + snr + rssi + header + path_byte. + if (frame.length < 5) return null; + + final snr = frame[1].toSigned(8); + final rssi = frame[2].toSigned(8); + final packet = frame.sublist(3); + + return parseRawMeshPacket(packet, snr: snr, rssi: rssi, now: now); +} + +/// Parses the raw mesh packet bytes (without the 0x88/snr/rssi BLE prefix). +/// Exposed separately so it can be unit-tested against known packet bytes. +RawPacketFrame? parseRawMeshPacket( + Uint8List packet, { + required int snr, + required int rssi, + DateTime? now, +}) { + if (packet.length < 2) return null; + + var offset = 0; + final header = packet[offset++]; + final routeType = header & 0x03; + final payloadType = (header >> 2) & 0x0F; + final payloadVersion = (header >> 6) & 0x03; + + // ROUTE_TYPE_TRANSPORT_FLOOD (0x00) and ROUTE_TYPE_TRANSPORT_DIRECT (0x03) + // carry 4 bytes of transport codes before the path byte. + if (routeType == 0x00 || routeType == 0x03) { + if (packet.length < offset + 4) return null; + offset += 4; + } + + if (packet.length < offset + 1) return null; + final pathByte = packet[offset++]; + final hashSize = ((pathByte & 0xC0) >> 6) + 1; + final hopCount = pathByte & 0x3F; + final pathByteLen = hopCount * hashSize; + + if (packet.length < offset + pathByteLen) return null; + final pathBytes = packet.sublist(offset, offset + pathByteLen); + offset += pathByteLen; + + final payload = packet.sublist(offset); + + final hashInput = Uint8List(1 + payload.length) + ..[0] = payloadType + ..setRange(1, 1 + payload.length, payload); + final packetHash = crypto.sha256 + .convert(hashInput) + .toString() + .substring(0, 16) + .toUpperCase(); + + return RawPacketFrame( + snr: snr, + rssi: rssi, + routeType: routeType, + payloadType: payloadType, + payloadVersion: payloadVersion, + pathByte: pathByte, + pathBytes: pathBytes, + payload: payload, + packetHash: packetHash, + receivedAtMs: (now ?? DateTime.now()).millisecondsSinceEpoch, + ); +} + +/// Short-lived buffer of recently-seen raw packets, used to correlate a +/// decoded chat message (arriving via the normal, separate sync path) back +/// to the raw reception(s) it came from. Bounded by both age and count so +/// it can't grow unbounded on a busy channel. +class RawPacketLog { + static const Duration _maxAge = Duration(seconds: 60); + static const int _maxEntries = 200; + + final List _frames = []; + + void add(RawPacketFrame frame) { + _frames.add(frame); + _prune(); + } + + /// All buffered frames matching [packetHash], oldest first. + List byPacketHash(String packetHash) { + _prune(); + return _frames.where((f) => f.packetHash == packetHash).toList(); + } + + /// All buffered frames of a given payload type, for brute-force matching + /// against a known plaintext (caller re-encrypts and compares payload + /// bytes directly rather than relying on this method to filter by hash). + List byPayloadType(int payloadType) { + _prune(); + return _frames.where((f) => f.payloadType == payloadType).toList(); + } + + void _prune() { + final cutoff = DateTime.now().subtract(_maxAge).millisecondsSinceEpoch; + _frames.removeWhere((f) => f.receivedAtMs < cutoff); + if (_frames.length > _maxEntries) { + _frames.removeRange(0, _frames.length - _maxEntries); + } + } +} diff --git a/lib/database/daos/contacts_dao.dart b/lib/database/daos/contacts_dao.dart index 8d49d44..1620380 100644 --- a/lib/database/daos/contacts_dao.dart +++ b/lib/database/daos/contacts_dao.dart @@ -112,6 +112,49 @@ class ContactsDao extends DatabaseAccessor return null; } + /// Get all contacts matching the first [prefixLength] bytes of their + /// public key, unlike [getContactByPublicKeyPrefix] which only returns + /// the first match. Used to detect and surface ambiguity when resolving + /// a short raw path-hop hash (1-3 bytes) against known contacts, since a + /// short prefix can legitimately collide between multiple contacts. + Future> getContactsByPublicKeyPrefix( + Uint8List prefix, { + int prefixLength = 6, + String? companionKey, + }) async { + if (prefix.isEmpty) return const []; + if (prefixLength <= 0) return const []; + + final effectivePrefixLength = + prefixLength > prefix.length ? prefix.length : prefixLength; + + final query = select(contacts); + if (companionKey != null && companionKey.isNotEmpty) { + query.where((t) => t.companionDeviceKey.equals(companionKey)); + } + + final allContacts = await query.get(); + final matches = []; + for (final contact in allContacts) { + final pk = contact.publicKey; + if (pk.length < effectivePrefixLength) continue; + + bool isMatch = true; + for (int i = 0; i < effectivePrefixLength; i++) { + if (pk[i] != prefix[i]) { + isMatch = false; + break; + } + } + + if (isMatch) { + matches.add(contact); + } + } + + return matches; + } + /// Get a single contact by hash (derived from full public key) /// Returns first match if multiple contacts have same hash Future getContactByHash(int hash) { @@ -433,8 +476,7 @@ class ContactsDao extends DatabaseAccessor ); final controller = StreamController(); - final contactsSub = - watchContactsByCompanion(companionKey).listen((_) { + final contactsSub = watchContactsByCompanion(companionKey).listen((_) { if (!controller.isClosed) controller.add(null); }); final messagesSub = db.messagesDao.watchMessageCount().listen((_) { diff --git a/lib/database/daos/message_paths_dao.dart b/lib/database/daos/message_paths_dao.dart new file mode 100644 index 0000000..9b2569c --- /dev/null +++ b/lib/database/daos/message_paths_dao.dart @@ -0,0 +1,46 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 +// http://creativecommons.org/licenses/by-nc-sa/4.0/ +// +// This file is part of TEAM-Flutter. +// Non-commercial use only. See LICENSE file for details. + +import 'package:drift/drift.dart'; +import '../database.dart'; +import '../tables.dart'; + +part 'message_paths_dao.g.dart'; + +/// DAO for per-message radio path observations (see MessagePaths table doc). +@DriftAccessor(tables: [MessagePaths]) +class MessagePathsDao extends DatabaseAccessor + with _$MessagePathsDaoMixin { + MessagePathsDao(super.db); + + /// Get all observed paths for a message, oldest first. + Future> getPathsByMessage(String messageId) { + return (select(messagePaths) + ..where((t) => t.messageId.equals(messageId)) + ..orderBy([ + (t) => + OrderingTerm(expression: t.receivedAt, mode: OrderingMode.asc), + ])) + .get(); + } + + /// Insert a newly-correlated path observation for a message. Silently + /// ignores a conflict on the (messageId, pathBytes) unique constraint -- + /// correlation can run concurrently for the same message (delivered via + /// PUSH and then again via a later sync), so this is the atomic + /// alternative to an application-side check-then-insert race. + Future insertPath(MessagePathsCompanion path) { + return into(messagePaths).insert(path, mode: InsertMode.insertOrIgnore); + } + + /// Delete paths older than a timestamp (retention cleanup). + Future deletePathsOlderThan(int timestamp) { + return (delete(messagePaths) + ..where((t) => t.receivedAt.isSmallerThanValue(timestamp))) + .go(); + } +} diff --git a/lib/database/daos/message_paths_dao.g.dart b/lib/database/daos/message_paths_dao.g.dart new file mode 100644 index 0000000..6c070d4 --- /dev/null +++ b/lib/database/daos/message_paths_dao.g.dart @@ -0,0 +1,8 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message_paths_dao.dart'; + +// ignore_for_file: type=lint +mixin _$MessagePathsDaoMixin on DatabaseAccessor { + $MessagePathsTable get messagePaths => attachedDatabase.messagePaths; +} diff --git a/lib/database/database.dart b/lib/database/database.dart index 2d22998..6e7d4ae 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -15,6 +15,7 @@ import 'tables.dart'; import 'daos/contacts_dao.dart'; import 'daos/channels_dao.dart'; import 'daos/messages_dao.dart'; +import 'daos/message_paths_dao.dart'; import 'daos/waypoints_dao.dart'; import 'daos/ack_records_dao.dart'; import 'daos/companion_devices_dao.dart'; @@ -44,6 +45,7 @@ typedef AckRecord = AckRecordData; Contacts, Channels, Messages, + MessagePaths, Waypoints, CompanionDevices, ContactDisplayStates, @@ -56,6 +58,7 @@ typedef AckRecord = AckRecordData; ContactsDao, ChannelsDao, MessagesDao, + MessagePathsDao, WaypointsDao, AckRecordsDao, CompanionDevicesDao, @@ -165,11 +168,14 @@ class AppDatabase extends _$AppDatabase { '[Migration] v7->v8: importedOverlayMaps, favorites, channel notification mode'); } - // Migration from schema version 8 to 9: Add per-message hop count and SNR + // Migration from schema version 8 to 9: per-message hop count/SNR, + // and the message_paths table for multi-path routing detail. if (from <= 8 && to >= 9) { await m.addColumn(messages, messages.hopCount); await m.addColumn(messages, messages.snr); - print('[Migration] v8->v9: added hopCount, snr to messages table'); + await m.createTable(messagePaths); + print( + '[Migration] v8->v9: added hopCount/snr to messages, created message_paths table'); } }, ); diff --git a/lib/database/database.g.dart b/lib/database/database.g.dart index 5db282a..6b1c9b2 100644 --- a/lib/database/database.g.dart +++ b/lib/database/database.g.dart @@ -2227,6 +2227,383 @@ class MessagesCompanion extends UpdateCompanion { } } +class $MessagePathsTable extends MessagePaths + with TableInfo<$MessagePathsTable, MessagePathData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $MessagePathsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _messageIdMeta = + const VerificationMeta('messageId'); + @override + late final GeneratedColumn messageId = GeneratedColumn( + 'message_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _pathByteMeta = + const VerificationMeta('pathByte'); + @override + late final GeneratedColumn pathByte = GeneratedColumn( + 'path_byte', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const VerificationMeta _pathBytesMeta = + const VerificationMeta('pathBytes'); + @override + late final GeneratedColumn pathBytes = GeneratedColumn( + 'path_bytes', aliasedName, false, + type: DriftSqlType.blob, requiredDuringInsert: true); + static const VerificationMeta _snrMeta = const VerificationMeta('snr'); + @override + late final GeneratedColumn snr = GeneratedColumn( + 'snr', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _rssiMeta = const VerificationMeta('rssi'); + @override + late final GeneratedColumn rssi = GeneratedColumn( + 'rssi', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _receivedAtMeta = + const VerificationMeta('receivedAt'); + @override + late final GeneratedColumn receivedAt = GeneratedColumn( + 'received_at', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => + [id, messageId, pathByte, pathBytes, snr, rssi, receivedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'message_paths'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('message_id')) { + context.handle(_messageIdMeta, + messageId.isAcceptableOrUnknown(data['message_id']!, _messageIdMeta)); + } else if (isInserting) { + context.missing(_messageIdMeta); + } + if (data.containsKey('path_byte')) { + context.handle(_pathByteMeta, + pathByte.isAcceptableOrUnknown(data['path_byte']!, _pathByteMeta)); + } else if (isInserting) { + context.missing(_pathByteMeta); + } + if (data.containsKey('path_bytes')) { + context.handle(_pathBytesMeta, + pathBytes.isAcceptableOrUnknown(data['path_bytes']!, _pathBytesMeta)); + } else if (isInserting) { + context.missing(_pathBytesMeta); + } + if (data.containsKey('snr')) { + context.handle( + _snrMeta, snr.isAcceptableOrUnknown(data['snr']!, _snrMeta)); + } + if (data.containsKey('rssi')) { + context.handle( + _rssiMeta, rssi.isAcceptableOrUnknown(data['rssi']!, _rssiMeta)); + } + if (data.containsKey('received_at')) { + context.handle( + _receivedAtMeta, + receivedAt.isAcceptableOrUnknown( + data['received_at']!, _receivedAtMeta)); + } else if (isInserting) { + context.missing(_receivedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {messageId, pathBytes}, + ]; + @override + MessagePathData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MessagePathData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + messageId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}message_id'])!, + pathByte: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}path_byte'])!, + pathBytes: attachedDatabase.typeMapping + .read(DriftSqlType.blob, data['${effectivePrefix}path_bytes'])!, + snr: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}snr']), + rssi: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}rssi']), + receivedAt: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}received_at'])!, + ); + } + + @override + $MessagePathsTable createAlias(String alias) { + return $MessagePathsTable(attachedDatabase, alias); + } +} + +class MessagePathData extends DataClass implements Insertable { + final int id; + final String messageId; + final int pathByte; + final Uint8List pathBytes; + final int? snr; + final int? rssi; + final int receivedAt; + const MessagePathData( + {required this.id, + required this.messageId, + required this.pathByte, + required this.pathBytes, + this.snr, + this.rssi, + required this.receivedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['message_id'] = Variable(messageId); + map['path_byte'] = Variable(pathByte); + map['path_bytes'] = Variable(pathBytes); + if (!nullToAbsent || snr != null) { + map['snr'] = Variable(snr); + } + if (!nullToAbsent || rssi != null) { + map['rssi'] = Variable(rssi); + } + map['received_at'] = Variable(receivedAt); + return map; + } + + MessagePathsCompanion toCompanion(bool nullToAbsent) { + return MessagePathsCompanion( + id: Value(id), + messageId: Value(messageId), + pathByte: Value(pathByte), + pathBytes: Value(pathBytes), + snr: snr == null && nullToAbsent ? const Value.absent() : Value(snr), + rssi: rssi == null && nullToAbsent ? const Value.absent() : Value(rssi), + receivedAt: Value(receivedAt), + ); + } + + factory MessagePathData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MessagePathData( + id: serializer.fromJson(json['id']), + messageId: serializer.fromJson(json['messageId']), + pathByte: serializer.fromJson(json['pathByte']), + pathBytes: serializer.fromJson(json['pathBytes']), + snr: serializer.fromJson(json['snr']), + rssi: serializer.fromJson(json['rssi']), + receivedAt: serializer.fromJson(json['receivedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'messageId': serializer.toJson(messageId), + 'pathByte': serializer.toJson(pathByte), + 'pathBytes': serializer.toJson(pathBytes), + 'snr': serializer.toJson(snr), + 'rssi': serializer.toJson(rssi), + 'receivedAt': serializer.toJson(receivedAt), + }; + } + + MessagePathData copyWith( + {int? id, + String? messageId, + int? pathByte, + Uint8List? pathBytes, + Value snr = const Value.absent(), + Value rssi = const Value.absent(), + int? receivedAt}) => + MessagePathData( + id: id ?? this.id, + messageId: messageId ?? this.messageId, + pathByte: pathByte ?? this.pathByte, + pathBytes: pathBytes ?? this.pathBytes, + snr: snr.present ? snr.value : this.snr, + rssi: rssi.present ? rssi.value : this.rssi, + receivedAt: receivedAt ?? this.receivedAt, + ); + MessagePathData copyWithCompanion(MessagePathsCompanion data) { + return MessagePathData( + id: data.id.present ? data.id.value : this.id, + messageId: data.messageId.present ? data.messageId.value : this.messageId, + pathByte: data.pathByte.present ? data.pathByte.value : this.pathByte, + pathBytes: data.pathBytes.present ? data.pathBytes.value : this.pathBytes, + snr: data.snr.present ? data.snr.value : this.snr, + rssi: data.rssi.present ? data.rssi.value : this.rssi, + receivedAt: + data.receivedAt.present ? data.receivedAt.value : this.receivedAt, + ); + } + + @override + String toString() { + return (StringBuffer('MessagePathData(') + ..write('id: $id, ') + ..write('messageId: $messageId, ') + ..write('pathByte: $pathByte, ') + ..write('pathBytes: $pathBytes, ') + ..write('snr: $snr, ') + ..write('rssi: $rssi, ') + ..write('receivedAt: $receivedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, messageId, pathByte, + $driftBlobEquality.hash(pathBytes), snr, rssi, receivedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MessagePathData && + other.id == this.id && + other.messageId == this.messageId && + other.pathByte == this.pathByte && + $driftBlobEquality.equals(other.pathBytes, this.pathBytes) && + other.snr == this.snr && + other.rssi == this.rssi && + other.receivedAt == this.receivedAt); +} + +class MessagePathsCompanion extends UpdateCompanion { + final Value id; + final Value messageId; + final Value pathByte; + final Value pathBytes; + final Value snr; + final Value rssi; + final Value receivedAt; + const MessagePathsCompanion({ + this.id = const Value.absent(), + this.messageId = const Value.absent(), + this.pathByte = const Value.absent(), + this.pathBytes = const Value.absent(), + this.snr = const Value.absent(), + this.rssi = const Value.absent(), + this.receivedAt = const Value.absent(), + }); + MessagePathsCompanion.insert({ + this.id = const Value.absent(), + required String messageId, + required int pathByte, + required Uint8List pathBytes, + this.snr = const Value.absent(), + this.rssi = const Value.absent(), + required int receivedAt, + }) : messageId = Value(messageId), + pathByte = Value(pathByte), + pathBytes = Value(pathBytes), + receivedAt = Value(receivedAt); + static Insertable custom({ + Expression? id, + Expression? messageId, + Expression? pathByte, + Expression? pathBytes, + Expression? snr, + Expression? rssi, + Expression? receivedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (messageId != null) 'message_id': messageId, + if (pathByte != null) 'path_byte': pathByte, + if (pathBytes != null) 'path_bytes': pathBytes, + if (snr != null) 'snr': snr, + if (rssi != null) 'rssi': rssi, + if (receivedAt != null) 'received_at': receivedAt, + }); + } + + MessagePathsCompanion copyWith( + {Value? id, + Value? messageId, + Value? pathByte, + Value? pathBytes, + Value? snr, + Value? rssi, + Value? receivedAt}) { + return MessagePathsCompanion( + id: id ?? this.id, + messageId: messageId ?? this.messageId, + pathByte: pathByte ?? this.pathByte, + pathBytes: pathBytes ?? this.pathBytes, + snr: snr ?? this.snr, + rssi: rssi ?? this.rssi, + receivedAt: receivedAt ?? this.receivedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (messageId.present) { + map['message_id'] = Variable(messageId.value); + } + if (pathByte.present) { + map['path_byte'] = Variable(pathByte.value); + } + if (pathBytes.present) { + map['path_bytes'] = Variable(pathBytes.value); + } + if (snr.present) { + map['snr'] = Variable(snr.value); + } + if (rssi.present) { + map['rssi'] = Variable(rssi.value); + } + if (receivedAt.present) { + map['received_at'] = Variable(receivedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MessagePathsCompanion(') + ..write('id: $id, ') + ..write('messageId: $messageId, ') + ..write('pathByte: $pathByte, ') + ..write('pathBytes: $pathBytes, ') + ..write('snr: $snr, ') + ..write('rssi: $rssi, ') + ..write('receivedAt: $receivedAt') + ..write(')')) + .toString(); + } +} + class $WaypointsTable extends Waypoints with TableInfo<$WaypointsTable, WaypointData> { @override @@ -5940,6 +6317,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $ContactsTable contacts = $ContactsTable(this); late final $ChannelsTable channels = $ChannelsTable(this); late final $MessagesTable messages = $MessagesTable(this); + late final $MessagePathsTable messagePaths = $MessagePathsTable(this); late final $WaypointsTable waypoints = $WaypointsTable(this); late final $CompanionDevicesTable companionDevices = $CompanionDevicesTable(this); @@ -5955,6 +6333,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final ContactsDao contactsDao = ContactsDao(this as AppDatabase); late final ChannelsDao channelsDao = ChannelsDao(this as AppDatabase); late final MessagesDao messagesDao = MessagesDao(this as AppDatabase); + late final MessagePathsDao messagePathsDao = + MessagePathsDao(this as AppDatabase); late final WaypointsDao waypointsDao = WaypointsDao(this as AppDatabase); late final AckRecordsDao ackRecordsDao = AckRecordsDao(this as AppDatabase); late final CompanionDevicesDao companionDevicesDao = @@ -5971,6 +6351,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { contacts, channels, messages, + messagePaths, waypoints, companionDevices, contactDisplayStates, @@ -6930,6 +7311,203 @@ typedef $$MessagesTableProcessedTableManager = ProcessedTableManager< (MessageData, BaseReferences<_$AppDatabase, $MessagesTable, MessageData>), MessageData, PrefetchHooks Function()>; +typedef $$MessagePathsTableCreateCompanionBuilder = MessagePathsCompanion + Function({ + Value id, + required String messageId, + required int pathByte, + required Uint8List pathBytes, + Value snr, + Value rssi, + required int receivedAt, +}); +typedef $$MessagePathsTableUpdateCompanionBuilder = MessagePathsCompanion + Function({ + Value id, + Value messageId, + Value pathByte, + Value pathBytes, + Value snr, + Value rssi, + Value receivedAt, +}); + +class $$MessagePathsTableFilterComposer + extends Composer<_$AppDatabase, $MessagePathsTable> { + $$MessagePathsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get messageId => $composableBuilder( + column: $table.messageId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get pathByte => $composableBuilder( + column: $table.pathByte, builder: (column) => ColumnFilters(column)); + + ColumnFilters get pathBytes => $composableBuilder( + column: $table.pathBytes, builder: (column) => ColumnFilters(column)); + + ColumnFilters get snr => $composableBuilder( + column: $table.snr, builder: (column) => ColumnFilters(column)); + + ColumnFilters get rssi => $composableBuilder( + column: $table.rssi, builder: (column) => ColumnFilters(column)); + + ColumnFilters get receivedAt => $composableBuilder( + column: $table.receivedAt, builder: (column) => ColumnFilters(column)); +} + +class $$MessagePathsTableOrderingComposer + extends Composer<_$AppDatabase, $MessagePathsTable> { + $$MessagePathsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get messageId => $composableBuilder( + column: $table.messageId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get pathByte => $composableBuilder( + column: $table.pathByte, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get pathBytes => $composableBuilder( + column: $table.pathBytes, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get snr => $composableBuilder( + column: $table.snr, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get rssi => $composableBuilder( + column: $table.rssi, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get receivedAt => $composableBuilder( + column: $table.receivedAt, builder: (column) => ColumnOrderings(column)); +} + +class $$MessagePathsTableAnnotationComposer + extends Composer<_$AppDatabase, $MessagePathsTable> { + $$MessagePathsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get messageId => + $composableBuilder(column: $table.messageId, builder: (column) => column); + + GeneratedColumn get pathByte => + $composableBuilder(column: $table.pathByte, builder: (column) => column); + + GeneratedColumn get pathBytes => + $composableBuilder(column: $table.pathBytes, builder: (column) => column); + + GeneratedColumn get snr => + $composableBuilder(column: $table.snr, builder: (column) => column); + + GeneratedColumn get rssi => + $composableBuilder(column: $table.rssi, builder: (column) => column); + + GeneratedColumn get receivedAt => $composableBuilder( + column: $table.receivedAt, builder: (column) => column); +} + +class $$MessagePathsTableTableManager extends RootTableManager< + _$AppDatabase, + $MessagePathsTable, + MessagePathData, + $$MessagePathsTableFilterComposer, + $$MessagePathsTableOrderingComposer, + $$MessagePathsTableAnnotationComposer, + $$MessagePathsTableCreateCompanionBuilder, + $$MessagePathsTableUpdateCompanionBuilder, + ( + MessagePathData, + BaseReferences<_$AppDatabase, $MessagePathsTable, MessagePathData> + ), + MessagePathData, + PrefetchHooks Function()> { + $$MessagePathsTableTableManager(_$AppDatabase db, $MessagePathsTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$MessagePathsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$MessagePathsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$MessagePathsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value messageId = const Value.absent(), + Value pathByte = const Value.absent(), + Value pathBytes = const Value.absent(), + Value snr = const Value.absent(), + Value rssi = const Value.absent(), + Value receivedAt = const Value.absent(), + }) => + MessagePathsCompanion( + id: id, + messageId: messageId, + pathByte: pathByte, + pathBytes: pathBytes, + snr: snr, + rssi: rssi, + receivedAt: receivedAt, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required String messageId, + required int pathByte, + required Uint8List pathBytes, + Value snr = const Value.absent(), + Value rssi = const Value.absent(), + required int receivedAt, + }) => + MessagePathsCompanion.insert( + id: id, + messageId: messageId, + pathByte: pathByte, + pathBytes: pathBytes, + snr: snr, + rssi: rssi, + receivedAt: receivedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$MessagePathsTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $MessagePathsTable, + MessagePathData, + $$MessagePathsTableFilterComposer, + $$MessagePathsTableOrderingComposer, + $$MessagePathsTableAnnotationComposer, + $$MessagePathsTableCreateCompanionBuilder, + $$MessagePathsTableUpdateCompanionBuilder, + ( + MessagePathData, + BaseReferences<_$AppDatabase, $MessagePathsTable, MessagePathData> + ), + MessagePathData, + PrefetchHooks Function()>; typedef $$WaypointsTableCreateCompanionBuilder = WaypointsCompanion Function({ required String id, Value meshId, @@ -8718,6 +9296,8 @@ class $AppDatabaseManager { $$ChannelsTableTableManager(_db, _db.channels); $$MessagesTableTableManager get messages => $$MessagesTableTableManager(_db, _db.messages); + $$MessagePathsTableTableManager get messagePaths => + $$MessagePathsTableTableManager(_db, _db.messagePaths); $$WaypointsTableTableManager get waypoints => $$WaypointsTableTableManager(_db, _db.waypoints); $$CompanionDevicesTableTableManager get companionDevices => diff --git a/lib/database/tables.dart b/lib/database/tables.dart index 87c5983..46f24c9 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -98,6 +98,35 @@ class Messages extends Table { 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/repositories/message_repository.dart b/lib/repositories/message_repository.dart index ce6392a..a71f009 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(); @@ -208,6 +219,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), @@ -857,20 +878,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) { @@ -2110,6 +2289,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/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/pubspec.lock b/pubspec.lock index 9f0aeb7..35fd96c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -737,10 +737,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -753,10 +753,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -949,6 +949,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: "direct main" + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" polylabel: dependency: transitive description: @@ -1270,10 +1278,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" timezone: dependency: transitive description: @@ -1459,5 +1467,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1566550..a0e4ad8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: # Utilities crypto: ^3.0.3 + pointycastle: ^4.0.0 http: ^1.2.0 package_info_plus: ^8.0.0 battery_plus: ^6.2.0 From d5c45899a4467d56720e11c93979b29a453d7da3 Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sat, 18 Jul 2026 19:00:03 -0700 Subject: [PATCH 4/9] Issue 65: multi-path message routing UI Wires the correlated MessagePaths data (from the previous commit) into the chat UI: - Message path sheet shows every distinct path a message was received on -- each with real SNR/RSSI and named hop(s) resolved against known contacts, falling back to the raw hex identifier when a hop isn't a known contact and to the message-level hopCount/snr summary when no raw-frame correlation exists at all (DMs, or a channel message that never matched). - Ambiguous hops (a short hash prefix matching more than one known contact) show all candidates and are visually flagged. - Chat bubbles now show a MessageHopBadge: the reliable single-value (d)/(N) summary immediately, swapping to a real multi-path summary like (d/1) once correlation data loads for that message -- this was the actual point of the whole effort, not just the detail sheet. - getContactsByPublicKeyPrefix (all-matches) and MessageRepository.getMessagePaths added as plumbing. --- lib/l10n/app_de.arb | 39 +++ lib/l10n/app_en.arb | 39 +++ lib/l10n/app_localizations.dart | 36 +++ lib/l10n/app_localizations_de.dart | 30 +++ lib/l10n/app_localizations_en.dart | 30 +++ lib/repositories/contact_repository.dart | 33 ++- lib/repositories/message_repository.dart | 7 + lib/screens/channel_chat_screen.dart | 9 +- lib/screens/direct_message_screen.dart | 9 +- lib/utils/radio_path_utils.dart | 46 ++++ lib/widgets/message_hop_badge.dart | 46 ++++ lib/widgets/message_path_sheet.dart | 319 ++++++++++++++++++++--- 12 files changed, 588 insertions(+), 55 deletions(-) create mode 100644 lib/utils/radio_path_utils.dart create mode 100644 lib/widgets/message_hop_badge.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 94eff95..beb4ae1 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -159,6 +159,45 @@ "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 b1d7b03..40e0864 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -159,6 +159,45 @@ "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 ae2bec4..b428355 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -902,6 +902,42 @@ abstract class AppLocalizations { /// **'{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 2d7615e..a77896a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -429,6 +429,36 @@ class AppLocalizationsDe extends AppLocalizations { 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 da23c40..f88d7a6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -425,6 +425,36 @@ class AppLocalizationsEn extends AppLocalizations { return '$count $_temp0'; } + @override + String pathNumber(int number) { + return 'Path $number'; + } + + @override + String snrValue(String value) { + return '$value dB SNR'; + } + + @override + String rssiValue(int value) { + return '$value dBm RSSI'; + } + + @override + String hopLabel(int number, String name) { + return 'Hop $number: $name'; + } + + @override + String hopLabelWithSignal(int number, String name, String signal) { + return 'Hop $number: $name — $signal'; + } + + @override + String ambiguousHop(String names) { + return '$names (ambiguous)'; + } + @override String get notificationsMuted => '🔕 Muted'; diff --git a/lib/repositories/contact_repository.dart b/lib/repositories/contact_repository.dart index 9bf2317..fcdb671 100644 --- a/lib/repositories/contact_repository.dart +++ b/lib/repositories/contact_repository.dart @@ -434,7 +434,8 @@ class ContactRepository { if (companionKey != null && companionKey.isNotEmpty) { final query = _contactsDao.selectOnly(_contactsDao.contacts) ..addColumns([_contactsDao.contacts.hash.count()]) - ..where(_contactsDao.contacts.companionDeviceKey.equals(companionKey)); + ..where( + _contactsDao.contacts.companionDeviceKey.equals(companionKey)); return query .watchSingle() .map((row) => row.read(_contactsDao.contacts.hash.count()) ?? 0) @@ -464,6 +465,21 @@ class ContactRepository { return _contactsDao.setFavorite(publicKey, isFavorite); } + /// All contacts matching a short public-key prefix (e.g. a raw radio path + /// hop hash, 1-3 bytes). Unlike a single-match lookup, this surfaces + /// ambiguity when a short prefix collides between multiple contacts. + Future> getContactsByPublicKeyPrefix( + Uint8List prefix, { + int prefixLength = 6, + String? companionKey, + }) { + return _contactsDao.getContactsByPublicKeyPrefix( + prefix, + prefixLength: prefixLength, + companionKey: companionKey, + ); + } + /// Delete a single contact from the local DB and from the companion (if connected). /// A "not found" response from the companion is not an error. Future deleteContact(ContactData contact) async { @@ -481,19 +497,18 @@ class ContactRepository { .millisecondsSinceEpoch; final nowMs = DateTime.now().millisecondsSinceEpoch; - final stale = (await _contactsDao.getAllContacts()) - .where((c) { - // Clamp future timestamps to now (companion clock may be ahead). - final effectiveLastSeen = c.lastSeen > nowMs ? nowMs : c.lastSeen; - return effectiveLastSeen < cutoffMs && !c.isFavorite; - }) - .toList(); + final stale = (await _contactsDao.getAllContacts()).where((c) { + // Clamp future timestamps to now (companion clock may be ahead). + final effectiveLastSeen = c.lastSeen > nowMs ? nowMs : c.lastSeen; + return effectiveLastSeen < cutoffMs && !c.isFavorite; + }).toList(); for (final contact in stale) { await deleteContact(contact); } - debugPrint('[ContactPurge] Removed ${stale.length} contacts older than $days days'); + debugPrint( + '[ContactPurge] Removed ${stale.length} contacts older than $days days'); return stale.length; } diff --git a/lib/repositories/message_repository.dart b/lib/repositories/message_repository.dart index a71f009..fd44b31 100644 --- a/lib/repositories/message_repository.dart +++ b/lib/repositories/message_repository.dart @@ -150,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() diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 440cafa..31b6a75 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -20,6 +20,7 @@ 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'; @@ -540,10 +541,9 @@ class _ChannelChatScreenState extends State { ), if (message.hopCount != null) ...[ const SizedBox(width: 4), - Text( - message.hopCount == 0 - ? '(d)' - : '(${message.hopCount})', + MessageHopBadge( + messageId: message.id, + fallbackHopCount: message.hopCount!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.primary.withOpacity(0.6), @@ -650,6 +650,7 @@ class _ChannelChatScreenState extends State { showModalBottomSheet( context: context, builder: (_) => MessagePathSheet( + messageId: message.id, senderName: senderName, hopCount: message.hopCount!, timestamp: DateTime.fromMillisecondsSinceEpoch( diff --git a/lib/screens/direct_message_screen.dart b/lib/screens/direct_message_screen.dart index 8eabb63..0bb891b 100644 --- a/lib/screens/direct_message_screen.dart +++ b/lib/screens/direct_message_screen.dart @@ -17,6 +17,7 @@ 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'; @@ -399,10 +400,9 @@ class _DirectMessageScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ if (!isFromMe && message.hopCount != null) ...[ - Text( - message.hopCount == 0 - ? '(d)' - : '(${message.hopCount})', + MessageHopBadge( + messageId: message.id, + fallbackHopCount: message.hopCount!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant .withOpacity(0.7), @@ -612,6 +612,7 @@ class _DirectMessageScreenState extends State { showModalBottomSheet( context: context, builder: (_) => MessagePathSheet( + messageId: message.id, senderName: widget.contact.name ?? 'Unknown Contact', hopCount: message.hopCount!, timestamp: DateTime.fromMillisecondsSinceEpoch( diff --git a/lib/utils/radio_path_utils.dart b/lib/utils/radio_path_utils.dart new file mode 100644 index 0000000..32b18a6 --- /dev/null +++ b/lib/utils/radio_path_utils.dart @@ -0,0 +1,46 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 + +import 'dart:typed_data'; + +/// Decodes a raw wire path_length byte into (hopCount, hashSize), per +/// docs/packet_format.md: bits 0-5 are hop count, bits 6-7 are hash-size +/// mode (hashSize = mode + 1, so 1/2/3 bytes per hop; mode 3 is reserved). +({int hopCount, int hashSize}) decodePathByte(int pathByte) { + final hopCount = pathByte & 0x3F; + final hashSize = ((pathByte & 0xC0) >> 6) + 1; + return (hopCount: hopCount, hashSize: hashSize); +} + +/// Splits raw path bytes into per-hop identifier chunks, using the hop +/// count/hash size encoded in [pathByte]. Falls back to 1-byte chunks if +/// the byte counts don't line up (shouldn't happen for well-formed data, +/// but keeps this robust against unexpected input). +List splitPathHops(int pathByte, Uint8List pathBytes) { + final decoded = decodePathByte(pathByte); + if (decoded.hopCount == 0 || pathBytes.isEmpty) return const []; + + final expectedLen = decoded.hopCount * decoded.hashSize; + final hashSize = expectedLen == pathBytes.length ? decoded.hashSize : 1; + + final hops = []; + for (var i = 0; i + hashSize <= pathBytes.length; i += hashSize) { + hops.add(pathBytes.sublist(i, i + hashSize)); + } + return hops; +} + +/// Formats a hop count for display: "Direct" for 0, "N hop(s)" otherwise. +/// Callers needing localized text should use AppLocalizations directly; +/// this is for the compact non-localized summary badge only (see +/// formatHopCountsBadge). +String hopCountBadgeToken(int hopCount) => hopCount == 0 ? 'd' : '$hopCount'; + +/// Compact multi-path summary badge, e.g. "d/1" for a message heard both +/// directly and via one relay hop. Mirrors the reference client's +/// formatHopCounts: sorted ascending, direct shown as "d". +String formatHopCountsBadge(List hopCounts) { + if (hopCounts.isEmpty) return ''; + final sorted = [...hopCounts]..sort(); + return sorted.map(hopCountBadgeToken).join('/'); +} diff --git a/lib/widgets/message_hop_badge.dart b/lib/widgets/message_hop_badge.dart new file mode 100644 index 0000000..9dbd57f --- /dev/null +++ b/lib/widgets/message_hop_badge.dart @@ -0,0 +1,46 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../repositories/message_repository.dart'; +import '../utils/radio_path_utils.dart'; + +/// Inline `(d)` / `(N)` / `(d/1)` hop badge for a message bubble. +/// +/// Renders the Phase 1 single-value summary (from [fallbackHopCount], +/// firmware's own hopCount for this message) immediately, then swaps to the +/// real multi-path summary (e.g. "d/1" for a message heard both directly +/// and via a relay) once raw-frame correlation data loads, if any exists. +/// No loading flicker: the fallback is itself a valid, correct answer, just +/// potentially less complete. +class MessageHopBadge extends StatelessWidget { + final String messageId; + final int fallbackHopCount; + final TextStyle? style; + + const MessageHopBadge({ + super.key, + required this.messageId, + required this.fallbackHopCount, + this.style, + }); + + @override + Widget build(BuildContext context) { + final fallback = '(${hopCountBadgeToken(fallbackHopCount)})'; + return FutureBuilder( + future: context.read().getMessagePaths(messageId), + builder: (context, snapshot) { + final paths = snapshot.data; + if (paths == null || paths.isEmpty) { + return Text(fallback, style: style); + } + final hopCounts = + paths.map((p) => decodePathByte(p.pathByte).hopCount).toList(); + return Text('(${formatHopCountsBadge(hopCounts)})', style: style); + }, + ); + } +} diff --git a/lib/widgets/message_path_sheet.dart b/lib/widgets/message_path_sheet.dart index 9db0858..803c3a5 100644 --- a/lib/widgets/message_path_sheet.dart +++ b/lib/widgets/message_path_sheet.dart @@ -1,81 +1,321 @@ // 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 a simple sender → receiver timeline for a message, -/// with the hop count (direct or N relays) between them. -/// -/// This is a summary view only — the underlying BLE protocol doesn't -/// currently expose which specific repeaters relayed a message, just a hop -/// count, so unlike richer reference clients this can't name individual -/// hops or show per-hop signal stats yet. -class MessagePathSheet extends StatelessWidget { +/// 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; - /// 0 = direct, >0 = number of relay hops. + /// 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; + + @override + void initState() { + super.initState(); + _load(); + } + + 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 isDirect = hopCount == 0; + final resolved = _resolvedPaths; return SafeArea( child: Padding( padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.messagePath, style: theme.textTheme.titleMedium), - const SizedBox(height: 4), - Text( - formatMessageTime(timestamp), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + child: resolved == null + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + : 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], + ), + ], + ], ), - ), - const SizedBox(height: 20), - _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), - ], - ), ), ); } } +/// 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; - const _TimelineNode({required this.label, required this.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: [ - CircleAvatar(radius: 4, backgroundColor: color), + Padding( + padding: const EdgeInsets.only(top: 4), + child: CircleAvatar(radius: 4, backgroundColor: color), + ), const SizedBox(width: 12), - Text(label, style: theme.textTheme.bodyMedium), + 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), + ], + ), + ), ], ); } @@ -100,12 +340,15 @@ class _TimelineConnector extends StatelessWidget { child: Container(width: 1, height: 28, color: color), ), const SizedBox(width: 20.5), - Text( - label, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + if (label.isNotEmpty) + Flexible( + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), ), - ), ], ), ); From cdf328bd568efd6f4e1e8a950ea40a7cc61cbf3f Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sat, 18 Jul 2026 20:01:18 -0700 Subject: [PATCH 5/9] Issue 67: remember desktop window size and position Adds window_manager and a small WindowStateService, desktop-only (not Android/iOS). Restores saved bounds on launch; persists on resize/move via a debounced SharedPreferences write, reusing the same prefs instance the rest of startup already fetches. Uses the continuous onWindowResize/onWindowMove events rather than the "finished" variants, since those are macOS/Windows only in window_manager and this needs to work on Linux. --- lib/main.dart | 81 ++++++++++++------- lib/services/window_state_service.dart | 66 +++++++++++++++ linux/flutter/generated_plugin_registrant.cc | 8 ++ linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 62 ++++++++++++-- pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 6 ++ windows/flutter/generated_plugins.cmake | 2 + 9 files changed, 194 insertions(+), 38 deletions(-) create mode 100644 lib/services/window_state_service.dart 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/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/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..0c176a7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,8 +4,10 @@ list(APPEND FLUTTER_PLUGIN_LIST gtk + screen_retriever_linux sqlite3_flutter_libs url_launcher_linux + window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index be1ee9a..de3eef5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,11 +14,13 @@ 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 wakelock_plus +import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) @@ -30,9 +32,11 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 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")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 9f0aeb7..7676121 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -737,10 +737,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -753,10 +753,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1045,6 +1045,46 @@ 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: @@ -1270,10 +1310,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" timezone: dependency: transitive description: @@ -1426,6 +1466,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.10.1" + 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: @@ -1459,5 +1507,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1566550..8949a5e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: # Utilities crypto: ^3.0.3 + 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..87994f3 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -8,9 +8,11 @@ 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 From 5e05a55d4428c0586eeedc37440a76c9bb711a22 Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sat, 18 Jul 2026 20:06:45 -0700 Subject: [PATCH 6/9] Issue 68: add Report Bug to About dialog Pre-fills a new GitHub issue with the app version and platform so bug reports don't start from a blank form. Uses url_launcher (also adds the Android 11+ manifest query it needs). --- android/app/src/main/AndroidManifest.xml | 7 +++ lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 2 + lib/l10n/app_localizations.dart | 12 +++++ lib/l10n/app_localizations_de.dart | 6 +++ lib/l10n/app_localizations_en.dart | 6 +++ lib/widgets/app_menu_button.dart | 31 ++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 48 +++++++++++++++---- pubspec.yaml | 1 + 10 files changed, 109 insertions(+), 8 deletions(-) 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/l10n/app_de.arb b/lib/l10n/app_de.arb index 9393f36..a9d679d 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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a6a6e11..14355af 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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 855eec9..c5020f5 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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ed82870..7216340 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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index efd3b48..3a6600c 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'; 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/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index be1ee9a..4f2ea53 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -18,6 +18,7 @@ import share_plus import shared_preferences_foundation import sqflite_darwin import sqlite3_flutter_libs +import url_launcher_macos import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -34,5 +35,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 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")) } diff --git a/pubspec.lock b/pubspec.lock index 9f0aeb7..75cefa0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -737,10 +737,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -753,10 +753,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1270,10 +1270,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" timezone: dependency: transitive description: @@ -1314,6 +1314,30 @@ 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: @@ -1322,6 +1346,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + 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: @@ -1459,5 +1491,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..83e438a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: # Utilities crypto: ^3.0.3 + url_launcher: ^6.3.2 http: ^1.2.0 package_info_plus: ^8.0.0 battery_plus: ^6.2.0 From 22026db027fd572ef3d6e7e6fc37768d845a3254 Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sat, 18 Jul 2026 20:29:10 -0700 Subject: [PATCH 7/9] Issue 65: fix message path sheet overflow on long paths The sheet's content wasn't scrollable, so a path with several hops (e.g. 8) overflowed the bottom of the screen. Caps the sheet at 80% of screen height and wraps content in a scrollable view with a visible scrollbar. --- lib/widgets/message_path_sheet.dart | 131 ++++++++++++++++------------ 1 file changed, 77 insertions(+), 54 deletions(-) diff --git a/lib/widgets/message_path_sheet.dart b/lib/widgets/message_path_sheet.dart index 803c3a5..066c9db 100644 --- a/lib/widgets/message_path_sheet.dart +++ b/lib/widgets/message_path_sheet.dart @@ -56,6 +56,7 @@ class _ResolvedPath { class _MessagePathSheetState extends State { List<_ResolvedPath>? _resolvedPaths; + final ScrollController _scrollController = ScrollController(); @override void initState() { @@ -63,6 +64,12 @@ class _MessagePathSheetState extends State { _load(); } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + Future _load() async { final messageRepo = context.read(); final contactRepo = context.read(); @@ -95,63 +102,79 @@ class _MessagePathSheetState extends State { final resolved = _resolvedPaths; return SafeArea( - child: Padding( - padding: const EdgeInsets.all(20), - child: resolved == null - ? const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Center(child: CircularProgressIndicator()), - ) - : 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, + 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, + const SizedBox(height: 4), + Text( + formatMessageTime(widget.timestamp), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - ), - _ResolvedPathTimeline( - senderName: widget.senderName, - path: resolved[i], + 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], + ), + ], + ], ), - ], - ], - ), + ), + ), + ), + ), ), ); } From 449befbaabd8f889eb2b074bf217d809e7a82cab Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sun, 19 Jul 2026 11:29:22 -0700 Subject: [PATCH 8/9] Issue 71: order messages by local receive order, not sender clock Ordering now uses SQLite's implicit rowid instead of the untrusted sender-embedded timestamp column. Adds receivedAt (local device clock) as the displayed message time; timestamp is kept as the raw sender-reported value. Unread-divider lookups now key off message id instead of the removed sequence column. --- lib/database/daos/messages_dao.dart | 82 +++++++++++--------------- lib/database/database.dart | 13 +++- lib/database/database.g.dart | 68 +++++++++++++++++++-- lib/database/tables.dart | 2 + lib/screens/channel_chat_screen.dart | 24 ++++---- lib/screens/direct_message_screen.dart | 24 ++++---- 6 files changed, 134 insertions(+), 79 deletions(-) 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 6e7d4ae..c61de2b 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -169,13 +169,22 @@ class AppDatabase extends _$AppDatabase { } // Migration from schema version 8 to 9: per-message hop count/SNR, - // and the message_paths table for multi-path routing detail. + // 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 to messages, created message_paths table'); + '[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 6b1c9b2..b36ebae 100644 --- a/lib/database/database.g.dart +++ b/lib/database/database.g.dart @@ -1558,6 +1558,14 @@ class $MessagesTable extends Messages 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, @@ -1575,7 +1583,8 @@ class $MessagesTable extends Messages isRead, companionDeviceKey, hopCount, - snr + snr, + receivedAt ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1680,6 +1689,12 @@ class $MessagesTable extends Messages context.handle( _snrMeta, snr.isAcceptableOrUnknown(data['snr']!, _snrMeta)); } + if (data.containsKey('received_at')) { + context.handle( + _receivedAtMeta, + receivedAt.isAcceptableOrUnknown( + data['received_at']!, _receivedAtMeta)); + } return context; } @@ -1721,6 +1736,8 @@ class $MessagesTable extends Messages .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'])!, ); } @@ -1747,6 +1764,7 @@ class MessageData extends DataClass implements Insertable { final String? companionDeviceKey; final int? hopCount; final int? snr; + final int receivedAt; const MessageData( {required this.id, required this.senderId, @@ -1763,7 +1781,8 @@ class MessageData extends DataClass implements Insertable { required this.isRead, this.companionDeviceKey, this.hopCount, - this.snr}); + this.snr, + required this.receivedAt}); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -1793,6 +1812,7 @@ class MessageData extends DataClass implements Insertable { if (!nullToAbsent || snr != null) { map['snr'] = Variable(snr); } + map['received_at'] = Variable(receivedAt); return map; } @@ -1822,6 +1842,7 @@ class MessageData extends DataClass implements Insertable { ? const Value.absent() : Value(hopCount), snr: snr == null && nullToAbsent ? const Value.absent() : Value(snr), + receivedAt: Value(receivedAt), ); } @@ -1846,6 +1867,7 @@ class MessageData extends DataClass implements Insertable { serializer.fromJson(json['companionDeviceKey']), hopCount: serializer.fromJson(json['hopCount']), snr: serializer.fromJson(json['snr']), + receivedAt: serializer.fromJson(json['receivedAt']), ); } @override @@ -1868,6 +1890,7 @@ class MessageData extends DataClass implements Insertable { 'companionDeviceKey': serializer.toJson(companionDeviceKey), 'hopCount': serializer.toJson(hopCount), 'snr': serializer.toJson(snr), + 'receivedAt': serializer.toJson(receivedAt), }; } @@ -1887,7 +1910,8 @@ class MessageData extends DataClass implements Insertable { bool? isRead, Value companionDeviceKey = const Value.absent(), Value hopCount = const Value.absent(), - Value snr = const Value.absent()}) => + Value snr = const Value.absent(), + int? receivedAt}) => MessageData( id: id ?? this.id, senderId: senderId ?? this.senderId, @@ -1907,6 +1931,7 @@ class MessageData extends DataClass implements Insertable { : 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( @@ -1936,6 +1961,8 @@ class MessageData extends DataClass implements Insertable { : 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, ); } @@ -1957,7 +1984,8 @@ class MessageData extends DataClass implements Insertable { ..write('isRead: $isRead, ') ..write('companionDeviceKey: $companionDeviceKey, ') ..write('hopCount: $hopCount, ') - ..write('snr: $snr') + ..write('snr: $snr, ') + ..write('receivedAt: $receivedAt') ..write(')')) .toString(); } @@ -1979,7 +2007,8 @@ class MessageData extends DataClass implements Insertable { isRead, companionDeviceKey, hopCount, - snr); + snr, + receivedAt); @override bool operator ==(Object other) => identical(this, other) || @@ -1999,7 +2028,8 @@ class MessageData extends DataClass implements Insertable { other.isRead == this.isRead && other.companionDeviceKey == this.companionDeviceKey && other.hopCount == this.hopCount && - other.snr == this.snr); + other.snr == this.snr && + other.receivedAt == this.receivedAt); } class MessagesCompanion extends UpdateCompanion { @@ -2019,6 +2049,7 @@ class MessagesCompanion extends UpdateCompanion { final Value companionDeviceKey; final Value hopCount; final Value snr; + final Value receivedAt; final Value rowid; const MessagesCompanion({ this.id = const Value.absent(), @@ -2037,6 +2068,7 @@ class MessagesCompanion extends UpdateCompanion { 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({ @@ -2056,6 +2088,7 @@ class MessagesCompanion extends UpdateCompanion { 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), @@ -2082,6 +2115,7 @@ class MessagesCompanion extends UpdateCompanion { Expression? companionDeviceKey, Expression? hopCount, Expression? snr, + Expression? receivedAt, Expression? rowid, }) { return RawValuesInsertable({ @@ -2102,6 +2136,7 @@ class MessagesCompanion extends UpdateCompanion { '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, }); } @@ -2123,6 +2158,7 @@ class MessagesCompanion extends UpdateCompanion { Value? companionDeviceKey, Value? hopCount, Value? snr, + Value? receivedAt, Value? rowid}) { return MessagesCompanion( id: id ?? this.id, @@ -2141,6 +2177,7 @@ class MessagesCompanion extends UpdateCompanion { companionDeviceKey: companionDeviceKey ?? this.companionDeviceKey, hopCount: hopCount ?? this.hopCount, snr: snr ?? this.snr, + receivedAt: receivedAt ?? this.receivedAt, rowid: rowid ?? this.rowid, ); } @@ -2196,6 +2233,9 @@ class MessagesCompanion extends UpdateCompanion { 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); } @@ -2221,6 +2261,7 @@ class MessagesCompanion extends UpdateCompanion { ..write('companionDeviceKey: $companionDeviceKey, ') ..write('hopCount: $hopCount, ') ..write('snr: $snr, ') + ..write('receivedAt: $receivedAt, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -6993,6 +7034,7 @@ typedef $$MessagesTableCreateCompanionBuilder = MessagesCompanion Function({ Value companionDeviceKey, Value hopCount, Value snr, + Value receivedAt, Value rowid, }); typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ @@ -7012,6 +7054,7 @@ typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ Value companionDeviceKey, Value hopCount, Value snr, + Value receivedAt, Value rowid, }); @@ -7073,6 +7116,9 @@ class $$MessagesTableFilterComposer ColumnFilters get snr => $composableBuilder( column: $table.snr, builder: (column) => ColumnFilters(column)); + + ColumnFilters get receivedAt => $composableBuilder( + column: $table.receivedAt, builder: (column) => ColumnFilters(column)); } class $$MessagesTableOrderingComposer @@ -7134,6 +7180,9 @@ class $$MessagesTableOrderingComposer ColumnOrderings get snr => $composableBuilder( column: $table.snr, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get receivedAt => $composableBuilder( + column: $table.receivedAt, builder: (column) => ColumnOrderings(column)); } class $$MessagesTableAnnotationComposer @@ -7192,6 +7241,9 @@ class $$MessagesTableAnnotationComposer GeneratedColumn get snr => $composableBuilder(column: $table.snr, builder: (column) => column); + + GeneratedColumn get receivedAt => $composableBuilder( + column: $table.receivedAt, builder: (column) => column); } class $$MessagesTableTableManager extends RootTableManager< @@ -7233,6 +7285,7 @@ class $$MessagesTableTableManager extends RootTableManager< 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( @@ -7252,6 +7305,7 @@ class $$MessagesTableTableManager extends RootTableManager< companionDeviceKey: companionDeviceKey, hopCount: hopCount, snr: snr, + receivedAt: receivedAt, rowid: rowid, ), createCompanionCallback: ({ @@ -7271,6 +7325,7 @@ class $$MessagesTableTableManager extends RootTableManager< 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( @@ -7290,6 +7345,7 @@ class $$MessagesTableTableManager extends RootTableManager< companionDeviceKey: companionDeviceKey, hopCount: hopCount, snr: snr, + receivedAt: receivedAt, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/database/tables.dart b/lib/database/tables.dart index 46f24c9..aea2385 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -93,6 +93,8 @@ class Messages extends Table { .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}; diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 31b6a75..07a560b 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -47,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; @@ -98,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. @@ -229,8 +229,8 @@ class _ChannelChatScreenState extends State { itemCount: _messages.length, itemBuilder: (context, index) { final message = _messages[_messages.length - 1 - index]; - final showUnreadDivider = _firstUnreadTimestamp != null && - message.timestamp == _firstUnreadTimestamp; + final showUnreadDivider = _firstUnreadMessageId != null && + message.id == _firstUnreadMessageId; return Column( children: [ @@ -338,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); @@ -489,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)); @@ -654,7 +654,7 @@ class _ChannelChatScreenState extends State { senderName: senderName, hopCount: message.hopCount!, timestamp: DateTime.fromMillisecondsSinceEpoch( - message.timestamp), + message.receivedAt), ), ); }, diff --git a/lib/screens/direct_message_screen.dart b/lib/screens/direct_message_screen.dart index 0bb891b..33c4c91 100644 --- a/lib/screens/direct_message_screen.dart +++ b/lib/screens/direct_message_screen.dart @@ -39,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 []; @@ -88,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; }); } @@ -203,8 +203,8 @@ class _DirectMessageScreenState extends State { itemCount: _messages.length, itemBuilder: (context, index) { final message = _messages[_messages.length - 1 - index]; - final showUnreadDivider = _firstUnreadTimestamp != null && - message.timestamp == _firstUnreadTimestamp; + final showUnreadDivider = _firstUnreadMessageId != null && + message.id == _firstUnreadMessageId; return Column( children: [ @@ -303,12 +303,12 @@ class _DirectMessageScreenState extends State { onSubmitted: (_) => _sendMessage(), onChanged: (text) { if (text.isNotEmpty && - _firstUnreadTimestamp != null) { + _firstUnreadMessageId != null) { _messageRepository.messagesDao .markContactMessagesAsRead( widget.contact.hash); setState(() { - _firstUnreadTimestamp = null; + _firstUnreadMessageId = null; }); } }, @@ -351,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), @@ -616,7 +616,7 @@ class _DirectMessageScreenState extends State { senderName: widget.contact.name ?? 'Unknown Contact', hopCount: message.hopCount!, timestamp: DateTime.fromMillisecondsSinceEpoch( - message.timestamp), + message.receivedAt), ), ); }, From 11d306dccbf8ec1e7e3a790167715434f9d54c91 Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sun, 19 Jul 2026 13:10:16 -0700 Subject: [PATCH 9/9] Fix duplicate couldNotOpenLink key from merge PR #68 and #64 each added this key independently; git's merge kept both non-conflicting insertions, which silently dropped the key from codegen entirely. Removed the duplicate and regenerated. --- lib/l10n/app_de.arb | 1 - lib/l10n/app_en.arb | 1 - lib/l10n/app_localizations.dart | 6 ------ lib/l10n/app_localizations_de.dart | 3 --- lib/l10n/app_localizations_en.dart | 3 --- 5 files changed, 14 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ee7e903..e988c4a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -361,7 +361,6 @@ "linkCopied": "Link kopiert", "copied": "Kopiert", - "couldNotOpenLink": "Link konnte nicht geöffnet werden", "debugLogsTitleWithCount": "Debug-Protokolle ({count})", "@debugLogsTitleWithCount": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ba426c1..88dae26 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -361,7 +361,6 @@ "linkCopied": "Link copied", "copied": "Copied", - "couldNotOpenLink": "Could not open link", "debugLogsTitleWithCount": "Debug Logs ({count})", "@debugLogsTitleWithCount": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8a050aa..60c47bd 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1508,12 +1508,6 @@ abstract class AppLocalizations { /// **'Copied'** String get copied; - /// No description provided for @couldNotOpenLink. - /// - /// In en, this message translates to: - /// **'Could not open link'** - String get couldNotOpenLink; - /// No description provided for @debugLogsTitleWithCount. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 8d19a8b..4e4b01b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -771,9 +771,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get copied => 'Kopiert'; - @override - String get couldNotOpenLink => 'Link konnte nicht geöffnet werden'; - @override String debugLogsTitleWithCount(int count) { return 'Debug-Protokolle ($count)'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8255b2d..5d68df0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -765,9 +765,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get copied => 'Copied'; - @override - String get couldNotOpenLink => 'Could not open link'; - @override String debugLogsTitleWithCount(int count) { return 'Debug Logs ($count)';