From 4e01f87139b73d34b38758243bf0a10af28dc170 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:21:33 -0500 Subject: [PATCH] fix(#1025): reconcile the epic with dev's PSK-keyed history Rebasing Epic #1021 onto dev met #1063, which keys channel history by the channel's PSK. On a slot reassignment it keeps the old channel's history, so re-adding that channel brings it back, and only a delete removes history outright. #1026's write path had cleared history on every change; the rebase resolution moves it onto dev's rules. A confirmed reassignment (an add, or an edit to a new key) purges only the slot's legacy keys and the in-memory entry. A confirmed delete removes history outright. All three still happen only after the radio's OK. Nothing in dev's suite caught a reassignment true-deleting the old channel's history: with that mutation applied, all tests passed. A new test (channel_write_history_identity_test.dart) drives the real PSK-keyed store and fails 2 of 4 under it. The Edit Channel dialog now checks for a busy channel table before #1103's identity check. Mid-sync the list is empty, so the identity check closed the dialog and blamed a changed channel. The busy check keeps the dialog and the user's edits open and gives the real reason. #1060's restoreChannelsToRadio now counts as a channel write in flight while it runs, so an add made during a restore is refused instead of choosing a slot the restore is about to fill (in the rebased #1026 commit). Full suite 1954 + 4 pass, analyze clean. Co-Authored-By: Claude Opus 5.5 --- lib/screens/channels_screen.dart | 13 +- .../channel_write_history_identity_test.dart | 128 ++++++++++++++++++ 2 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 test/connector/channel_write_history_identity_test.dart diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 81a83031..8a51269d 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -1747,12 +1747,18 @@ class _ChannelsScreenState extends State return; } + // Checked first: mid-sync the list is empty, so the identity + // check below would close the dialog and blame a changed + // channel. This keeps the dialog, and the user's edits, open + // and gives the real reason (#1021, #1026). + if (refuseWhileChannelTableBusy(dialogContext, connector)) { + return; + } // #1103 review: this dialog captured the channel when it // opened. If a sync replaced the slot's identity meanwhile // (the #1060 class), writing by the captured index would hit // the NEW occupant with the OLD dialog's rules. Refuse the - // write and say why; mid-sync the list is empty, so this - // also fails closed while slots are unconfirmed. + // write and say why. final live = connector.channels.cast().firstWhere( (c) => c?.index == channel.index, orElse: () => null, @@ -1767,9 +1773,6 @@ class _ChannelsScreenState extends State ); return; } - if (refuseWhileChannelTableBusy(dialogContext, connector)) { - return; - } final Uint8List psk; if (channelType == ChannelType.hashtag) { diff --git a/test/connector/channel_write_history_identity_test.dart b/test/connector/channel_write_history_identity_test.dart new file mode 100644 index 00000000..a1e3dc78 --- /dev/null +++ b/test/connector/channel_write_history_identity_test.dart @@ -0,0 +1,128 @@ +// #1026 on top of #1063: what a confirmed channel write does to PSK-keyed +// history. +// +// History is keyed by the channel's PSK (#194). Giving a slot to a different +// channel is a reassignment: the old channel's history is kept, so it comes +// back if that channel is ever re-added (#1063). Deleting a channel is the one +// path that removes its history outright. Both happen only after the radio's +// OK (#1026). Exercised through the real store, not the in-memory map. + +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/models/channel.dart'; +import 'package:meshcore_open/models/channel_message.dart'; +import 'package:meshcore_open/storage/channel_message_store.dart'; +import 'package:meshcore_open/storage/drift/blob_store.dart'; +import 'package:meshcore_open/storage/drift/offband_database.dart'; +import 'package:meshcore_open/storage/prefs_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Uint8List psk(int fill) => Uint8List.fromList(List.filled(16, fill)); + +List channelInfo(int index, String name, Uint8List key) => [ + respCodeChannelInfo, + index, + ...name.codeUnits, + ...List.filled(32 - name.length, 0), + ...key, +]; + +ChannelMessage msg(String text) => ChannelMessage( + senderName: 's', + text: text, + timestamp: DateTime.fromMillisecondsSinceEpoch(1000), + isOutgoing: false, + channelIndex: 0, + messageId: text, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late OffbandDatabase db; + late MeshCoreConnector connector; + + final weather = Channel(index: 0, name: 'Weather', psk: psk(0x22)); + + /// Reads the history stored under [weather]'s PSK, whatever slot it is in. + late ChannelMessageStore weatherHistory; + + Future settle() => Future.delayed(Duration.zero); + void ok() => connector.handleFrameForTest([respCodeOk]); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + PrefsManager.reset(); + await PrefsManager.initialize(); + db = OffbandDatabase(NativeDatabase.memory()); + BlobStore.overrideForTest(BlobStore(db)); + + connector = MeshCoreConnector(); + connector.sendFrameOverrideForTest = (_) {}; + connector.setConnectedForTest(); + connector.setSelfPublicKeyForTest( + Uint8List.fromList(List.generate(32, (i) => i)), + ); + connector.wireMessageStoreForTest(); + + // The radio holds Weather in slot 0. + await connector.getChannels(maxChannels: 1, force: true); + connector.handleFrameForTest(channelInfo(0, weather.name, weather.psk)); + await settle(); + expect(connector.channels.single.pskHex, weather.pskHex); + + weatherHistory = ChannelMessageStore() + ..setPublicKeyHex = connector.selfPublicKeyHex + ..channelPskResolver = (_) => weather.pskHex; + await weatherHistory.saveChannelMessages(0, [msg('kept')]); + expect(await weatherHistory.loadChannelMessages(0), hasLength(1)); + }); + + tearDown(() async { + BlobStore.clearTestOverride(); + await db.close(); + }); + + test( + 'a slot given to another channel keeps the old one\'s history', + () async { + final write = connector.setChannel(0, 'Other', psk(0x33), refresh: false); + ok(); + + expect(await write, ChannelWrite.ok); + expect(await weatherHistory.loadChannelMessages(0), hasLength(1)); + }, + ); + + test('a new key by edit keeps the old key\'s history too', () async { + final edit = connector.editChannel(weather, 'Weather', psk(0x99)); + await settle(); + ok(); // the clear + for (var i = 0; i < 50; i++) { + await settle(); + } + ok(); // the add + + expect(await edit, ChannelWrite.ok); + expect(await weatherHistory.loadChannelMessages(0), hasLength(1)); + }); + + test('a confirmed delete removes the channel\'s history', () async { + final delete = connector.deleteChannel(0, refresh: false); + ok(); + + expect(await delete, ChannelWrite.ok); + expect(await weatherHistory.loadChannelMessages(0), isEmpty); + }); + + test('a refused delete keeps it', () async { + final delete = connector.deleteChannel(0, refresh: false); + connector.handleFrameForTest([respCodeErr, errCodeNotFound]); + + expect(await delete, ChannelWrite.rejected); + expect(await weatherHistory.loadChannelMessages(0), hasLength(1)); + }); +}