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 <noreply@anthropic.com>
pull/1115/head
diffwireauto 6 days ago
parent 4548bf041e
commit 4e01f87139

@ -1747,12 +1747,18 @@ class _ChannelsScreenState extends State<ChannelsScreen>
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<Channel?>().firstWhere(
(c) => c?.index == channel.index,
orElse: () => null,
@ -1767,9 +1773,6 @@ class _ChannelsScreenState extends State<ChannelsScreen>
);
return;
}
if (refuseWhileChannelTableBusy(dialogContext, connector)) {
return;
}
final Uint8List psk;
if (channelType == ChannelType.hashtag) {

@ -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<int>.filled(16, fill));
List<int> channelInfo(int index, String name, Uint8List key) => [
respCodeChannelInfo,
index,
...name.codeUnits,
...List<int>.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<void> settle() => Future<void>.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<int>.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));
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.