Refactor storage classes to include companion's public key#277
Conversation
…ata loading/saving logic
There was a problem hiding this comment.
Pull request overview
This PR refactors multiple SharedPreferences-backed storage classes to namespace persisted data by the connected companion (by prefixing keys with the first 5 bytes / 10 hex chars of the companion’s public key) to prevent data bleed when switching between multiple companions.
Changes:
- Add
publicKeyHex-based key namespacing to mostlib/storage/*_store.dartclasses. - Wire up store key initialization from
MeshCoreConnector.selfPublicKeyHexonce self info is available. - Update Channels UI usage to set the key for
ChannelMessageStorebefore accessing persisted channel messages.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/storage/unread_store.dart | Namespaces unread-count persistence key by companion public key prefix. |
| lib/storage/message_store.dart | Namespaces message persistence keys by companion public key prefix. |
| lib/storage/contact_store.dart | Namespaces contacts persistence key by companion public key prefix. |
| lib/storage/contact_settings_store.dart | Namespaces contact settings keys by companion public key prefix. |
| lib/storage/contact_group_store.dart | Namespaces contact group persistence key by companion public key prefix. |
| lib/storage/contact_discovery_store.dart | Intended to namespace discovered contacts by companion, but currently does not. |
| lib/storage/community_store.dart | Namespaces community persistence key by companion public key prefix (requires callers to set it). |
| lib/storage/channel_store.dart | Namespaces channels persistence key by companion public key prefix. |
| lib/storage/channel_settings_store.dart | Namespaces channel settings keys by companion public key prefix. |
| lib/storage/channel_order_store.dart | Namespaces channel order persistence key by companion public key prefix. |
| lib/storage/channel_message_store.dart | Namespaces channel message keys by companion public key prefix (guards missing in mutating methods). |
| lib/screens/channels_screen.dart | Sets ChannelMessageStore public key prefix from connector. |
| lib/connector/meshcore_connector.dart | Adds selfPublicKeyHex and sets store key prefixes after SELF_INFO is received. |
Comments suppressed due to low confidence (2)
lib/storage/channel_message_store.dart:31
saveChannelMessages/clearChannelMessages/clearAllChannelMessagesdon’t guard against an unsetpublicKeyHex. With an empty key, these methods will write/remove keys under the shared_keyPrefixnamespace, which can cause cross-companion data bleed or mass deletion. Add the same early-return/log guard pattern used by the other stores before computingkeyFor/iteratinggetKeys().
/// Save messages for a specific channel
Future<void> saveChannelMessages(
int channelIndex,
List<ChannelMessage> messages,
) async {
final prefs = PrefsManager.instance;
final key = '$keyFor$channelIndex';
// Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
final jsonString = jsonEncode(jsonList);
await prefs.setString(key, jsonString);
}
lib/connector/meshcore_connector.dart:1208
loadDiscoveredContactCache()is no longer invoked during initial sync (and there are no other callers), so_discoveredContactswill always start empty on app launch even though_persistDiscoveredContacts()still saves updates. Add a call toloadDiscoveredContactCache()once the discovery store has been namespaced with the companion key (e.g., after self info is available).
_bleInitialSyncStarted = true;
await _requestDeviceInfo();
_startBatteryPolling();
final gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
);
if (!gotSelfInfo) {
await refreshDeviceInfo();
await _waitForSelfInfo(timeout: const Duration(seconds: 3));
}
await syncTime();
unawaited(getChannels());
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| set setPublicKeyHex(String value) => | ||
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; | ||
|
|
||
| String get keyFor => _keyPrefix; |
There was a problem hiding this comment.
keyFor currently ignores publicKeyHex (String get keyFor => _keyPrefix;), so discovered contacts will still be persisted under a shared key across companions. This defeats the PR’s isolation goal; update keyFor to incorporate the truncated public key (consistent with other stores).
| String get keyFor => _keyPrefix; | |
| String get keyFor => '$_keyPrefix_$publicKeyHex'; |
| Future<void> saveContacts(List<DiscoveryContact> contacts) async { | ||
| final prefs = PrefsManager.instance; | ||
| final jsonList = contacts.map(_toJson).toList(); | ||
| await prefs.setString(_key, jsonEncode(jsonList)); | ||
| await prefs.setString(keyFor, jsonEncode(jsonList)); | ||
| } |
There was a problem hiding this comment.
saveContacts does not guard against an unset publicKeyHex, unlike loadContacts. Once keyFor is made companion-specific, saving before setting the key would write to an empty-suffix key (or the wrong namespace) and can reintroduce data bleed; add the same early-return/log guard used by the other stores.
| if (publicKeyHex.isEmpty) { | ||
| appLogger.warn('Public key hex is not set. Cannot save messages.'); | ||
| return; | ||
| } | ||
| final prefs = PrefsManager.instance; | ||
| final key = '$_keyPrefix$contactKeyHex'; | ||
| final key = '$keyFor$contactKeyHex'; | ||
| final jsonList = messages.map(_messageToJson).toList(); | ||
| await prefs.setString(key, jsonEncode(jsonList)); |
There was a problem hiding this comment.
saveMessages/loadMessages now use keyFor (includes publicKeyHex), but clearMessages still removes '$_keyPrefix$contactKeyHex' (old/global key). This means removing a contact won’t clear its persisted conversation for the current companion, and may delete legacy/other-companion data instead; update clearMessages to use the same namespaced key as save/load (and apply the same empty-key guard).
|
|
||
| String publicKeyHex = ''; | ||
| set setPublicKeyHex(String value) => | ||
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; |
There was a problem hiding this comment.
Several stores use publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''. Using > 10 drops the key when the input is exactly 10 hex chars, and using '' for shorter values causes silent no-ops plus repeated warnings. Prefer >= 10 (or min(value.length, 10)) and consider keeping a shorter prefix rather than clearing it so the store remains usable in edge cases/testing.
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; | |
| publicKeyHex = value.length >= 10 ? value.substring(0, 10) : value; |
| /// Clear all channel messages | ||
| Future<void> clearAllChannelMessages() async { | ||
| final prefs = PrefsManager.instance; | ||
| final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix)); | ||
| final keys = prefs.getKeys().where((k) => k.startsWith(keyFor)); | ||
| for (var key in keys) { | ||
| await prefs.remove(key); | ||
| } |
There was a problem hiding this comment.
clearAllChannelMessages filters prefs.getKeys() with startsWith(keyFor). If publicKeyHex hasn’t been set (so keyFor is just _keyPrefix), this will match and remove all channel message keys for all companions. Ensure you bail out when publicKeyHex is empty (similar to loadChannelMessages) to avoid unintended data loss.
| final connector = context.watch<MeshCoreConnector>(); | ||
|
|
||
| final channelMessageStore = ChannelMessageStore(); | ||
| channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex; |
There was a problem hiding this comment.
This screen creates/uses a CommunityStore, but after this PR CommunityStore won’t load/save unless setPublicKeyHex is called. You’ve set the key for ChannelMessageStore here; do the same for _communityStore (and any other per-companion stores used by this screen) to avoid communities silently not persisting.
| channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex; | |
| channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex; | |
| _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; |
|
|
||
| // Now that we have self info, we can load all the persisted data for this node | ||
| _loadChannelOrder(); | ||
| loadContactCache(); |
There was a problem hiding this comment.
After setting the stores’ publicKeyHex, you reload several persisted datasets but not discovered contacts. Since loadDiscoveredContactCache() isn’t called elsewhere, add it here so the discovery list is restored per companion. Also, because _loadChannelOrder() now depends on publicKeyHex, consider removing the earlier _loadChannelOrder() call in initialize() to avoid warning logs and double-loading.
| loadContactCache(); | |
| loadContactCache(); | |
| loadDiscoveredContactCache(); |
| String publicKeyHex = ''; | ||
| set setPublicKeyHex(String value) => | ||
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; | ||
|
|
||
| String get keyFor => '$_keyPrefix$publicKeyHex'; | ||
|
|
||
| /// Load all communities from storage | ||
| Future<List<Community>> loadCommunities() async { | ||
| if (publicKeyHex.isEmpty) { | ||
| appLogger.warn('Public key hex is not set. Cannot load communities.'); | ||
| return []; | ||
| } |
There was a problem hiding this comment.
CommunityStore now hard-requires publicKeyHex to be set (otherwise load/save no-op). Current call sites that construct CommunityStore() (e.g. ChannelsScreen / CommunityQrScannerScreen) don’t set it, so communities will stop persisting/loading after this change. Either plumb the companion key into these screens (or accept it in the constructor) and ensure it’s always set before any operation.
… fix key reference in saveContacts method
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 21 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| String publicKeyHex = ''; | ||
| set setPublicKeyHex(String value) => | ||
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; |
There was a problem hiding this comment.
setPublicKeyHex uses value.length > 10, which clears publicKeyHex when the input is exactly 10 chars (the intended prefix length). Accept >= 10 (or clamp via min).
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; | |
| publicKeyHex = value.length >= 10 ? value.substring(0, 10) : ''; |
| final jsonString = prefs.getString(keyFor); | ||
| if (jsonString == null || jsonString.isEmpty) { |
There was a problem hiding this comment.
This store switched from the legacy key (communities_v1) to a scoped key (keyFor). Without a migration, existing users will appear to lose stored communities after upgrading. Consider migrating/copying the legacy value into keyFor the first time you load for a given publicKeyHex.
| final jsonString = prefs.getString(keyFor); | |
| if (jsonString == null || jsonString.isEmpty) { | |
| String? jsonString = prefs.getString(keyFor); | |
| if (jsonString == null || jsonString.isEmpty) { | |
| // Attempt migration from legacy unscoped key on first load | |
| final legacyJsonString = prefs.getString(_keyPrefix); | |
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | |
| appLogger.info( | |
| 'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor'); | |
| await prefs.setString(keyFor, legacyJsonString); | |
| jsonString = legacyJsonString; | |
| } | |
| } | |
| if (jsonString == null || jsonString.isEmpty) { |
| static const String _keyPrefix = 'channels'; | ||
| String publicKeyHex = ''; | ||
| set setPublicKeyHex(String value) => | ||
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; |
There was a problem hiding this comment.
setPublicKeyHex uses value.length > 10, which clears publicKeyHex when the provided value is exactly 10 chars (the intended 5-byte prefix). Accept >= 10 (or clamp).
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; | |
| publicKeyHex = value.length >= 10 ? value.substring(0, 10) : ''; |
| _loadChannelOrder(); | ||
| loadContactCache(); | ||
| loadChannelSettings(); | ||
| loadCachedChannels(); | ||
|
|
||
| // Load persisted channel messages | ||
| loadAllChannelMessages(); | ||
| loadUnreadState(); |
There was a problem hiding this comment.
These Future-returning load methods are called without await/unawaited. The repo already uses unawaited(...) elsewhere, so this likely triggers unawaited_futures warnings and makes sequencing less explicit. Wrap these calls in unawaited(...) (or make _handleSelfInfo async and await them).
| _loadChannelOrder(); | |
| loadContactCache(); | |
| loadChannelSettings(); | |
| loadCachedChannels(); | |
| // Load persisted channel messages | |
| loadAllChannelMessages(); | |
| loadUnreadState(); | |
| unawaited(_loadChannelOrder()); | |
| unawaited(loadContactCache()); | |
| unawaited(loadChannelSettings()); | |
| unawaited(loadCachedChannels()); | |
| // Load persisted channel messages | |
| unawaited(loadAllChannelMessages()); | |
| unawaited(loadUnreadState()); |
| set setPublicKeyHex(String value) => | ||
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; |
There was a problem hiding this comment.
setPublicKeyHex currently clears publicKeyHex unless value.length > 10. This rejects exactly-10-char prefixes (the intended 5 bytes) and can leave the store unscoped. Use a condition that accepts length 10 (and ideally derive the prefix via min(value.length, 10)).
| set setPublicKeyHex(String value) => | |
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; | |
| set setPublicKeyHex(String value) { | |
| if (value.isEmpty) { | |
| publicKeyHex = ''; | |
| } else if (value.length <= 10) { | |
| publicKeyHex = value; | |
| } else { | |
| publicKeyHex = value.substring(0, 10); | |
| } | |
| } |
| String get keyFor => '$_keyPrefix$publicKeyHex'; | ||
|
|
There was a problem hiding this comment.
keyFor now scopes message storage by companion, but clearMessages() in this class still removes the legacy unscoped key ($_keyPrefix$contactKeyHex). This means scoped messages won’t be cleared (and could also delete another companion’s legacy data). Update clearMessages() to use the same scoped key as saveMessages/loadMessages and add the same publicKeyHex guard.
|
|
||
| String publicKeyHex = ''; | ||
| set setPublicKeyHex(String value) => | ||
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; |
There was a problem hiding this comment.
setPublicKeyHex uses value.length > 10, which will set publicKeyHex to empty when the provided value is exactly 10 chars (the intended 5-byte / 10-hex-char prefix). Accept >= 10 (or clamp via min).
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; | |
| publicKeyHex = value.length >= 10 ? value.substring(0, 10) : ''; |
|
|
||
| String publicKeyHex = ''; | ||
| set setPublicKeyHex(String value) => | ||
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; |
There was a problem hiding this comment.
setPublicKeyHex currently requires value.length > 10, which rejects exactly-10-char prefixes and leaves the store unscoped. Since the intended prefix is 10 hex chars, accept >= 10 (or clamp).
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; | |
| publicKeyHex = value.length >= 10 ? value.substring(0, 10) : ''; |
|
|
||
| String publicKeyHex = ''; | ||
| set setPublicKeyHex(String value) => | ||
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; |
There was a problem hiding this comment.
setPublicKeyHex currently requires value.length > 10, which rejects exactly-10-char prefixes and leaves this store unscoped. Since the intended prefix is 10 hex chars, accept >= 10 (or clamp).
| publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; | |
| publicKeyHex = value.length >= 10 ? value.substring(0, 10) : ''; |
| Future<void> clearChannelMessages(int channelIndex) async { | ||
| final prefs = PrefsManager.instance; | ||
| final key = '$_keyPrefix$channelIndex'; | ||
| final key = '$keyFor$channelIndex'; | ||
| await prefs.remove(key); |
There was a problem hiding this comment.
clearChannelMessages doesn’t guard against an empty publicKeyHex. If it’s unset, this can delete unscoped (shared) channel message data. Add a guard similar to loadChannelMessages() to prevent deletions when the store isn’t scoped.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 796f6f4ccd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
| final prefs = PrefsManager.instance; | ||
| final key = '$_keyPrefix$contactKeyHex'; | ||
| final key = '$keyFor$contactKeyHex'; |
There was a problem hiding this comment.
Keep message deletion key format consistent
This change namespaces saved messages with keyFor, but clearMessages in the same class still deletes $_keyPrefix$contactKeyHex (without the companion prefix). When MeshCoreConnector removes a contact and calls clearMessages, the newly saved per-companion conversation is left behind and can reappear on next load, so delete semantics are now broken for all contacts saved after this refactor.
Useful? React with 👍 / 👎.
| if (publicKeyHex.isEmpty) { | ||
| appLogger.warn('Public key hex is not set. Cannot load communities.'); | ||
| return []; |
There was a problem hiding this comment.
Initialize CommunityStore namespace before enforcing guard
CommunityStore now returns early whenever publicKeyHex is unset, but no call site sets setPublicKeyHex before using it (e.g. community load/add flows in ChannelsScreen and CommunityQrScannerScreen). That makes loadCommunities always return [] and saveCommunities no-op, so creating/joining/managing communities no longer persists after this commit.
Useful? React with 👍 / 👎.
| if (publicKeyHex.isEmpty) { | ||
| appLogger.warn('Public key hex is not set. Cannot load contact groups.'); | ||
| return []; |
There was a problem hiding this comment.
Initialize ContactGroupStore key before load/save
ContactGroupStore now hard-fails reads/writes when publicKeyHex is empty, but the ContactsScreen usage never sets setPublicKeyHex. As a result, group data always loads as empty and save operations are dropped, so contact grouping persistence is effectively disabled by this change.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Future<void> clearAllChannelMessages() async { | ||
| final prefs = PrefsManager.instance; | ||
| final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix)); | ||
| final keys = prefs.getKeys().where((k) => k.startsWith(keyFor)); | ||
| for (var key in keys) { | ||
| await prefs.remove(key); | ||
| } |
There was a problem hiding this comment.
clearAllChannelMessages filters keys with startsWith(keyFor). If publicKeyHex is empty (because setPublicKeyHex hasn’t been called yet), keyFor becomes just the base prefix and this will match/remove all channel message keys across companions. Add the same publicKeyHex.isEmpty guard used in the load/save methods before iterating/removing keys.
| _loadChannelOrder(); | ||
| _loadDiscoveredContactCache(); | ||
|
|
There was a problem hiding this comment.
initialize() calls _loadChannelOrder() before setPublicKeyHex is applied to the stores (this only happens later once self info is available). With the new store guards, this first load will return an empty order (and log warnings), then notify listeners, and later reload again once self info arrives—potentially causing unnecessary UI churn/flicker. Consider deferring _loadChannelOrder() until after the stores are scoped to selfPublicKeyHex (or make the stores tolerate an unset key without logging/overwriting state).
| _loadChannelOrder(); | |
| _loadDiscoveredContactCache(); | |
| // Only load key-scoped stores once the self public key is available. | |
| if (_selfPublicKey != null) { | |
| _loadChannelOrder(); | |
| _loadDiscoveredContactCache(); | |
| } |
| bool get isLoadingChannels => _isLoadingChannels; | ||
| Stream<Uint8List> get receivedFrames => _receivedFramesController.stream; | ||
| Uint8List? get selfPublicKey => _selfPublicKey; | ||
| String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0)); |
There was a problem hiding this comment.
selfPublicKeyHex currently allocates a new Uint8List(0) every time the getter is called when _selfPublicKey is null. Since this getter may be read frequently during widget rebuilds, consider avoiding the allocation by explicitly returning '' when _selfPublicKey == null (or caching the computed hex string when the self key is set).
| String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0)); | |
| String get selfPublicKeyHex { | |
| final key = _selfPublicKey; | |
| if (key == null || key.isEmpty) { | |
| return ''; | |
| } | |
| return pubKeyToHex(key); | |
| } |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 9 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| String? jsonString = prefs.getString(_keyPrefix); | ||
| if (jsonString == null || jsonString.isEmpty) { | ||
| // Attempt migration from legacy unscoped key on first load | ||
| final legacyJsonString = prefs.getString(_keyPrefix); | ||
| prefs.remove(_keyPrefix); | ||
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { |
There was a problem hiding this comment.
loadGroups() reads from _keyPrefix instead of the scoped keyFor, and the migration path reads/removes _keyPrefix again. After migration, future loads won't read the scoped value written under keyFor, effectively dropping persisted groups. Read from keyFor first, then fall back to legacy _keyPrefix for one-time migration.
| String? jsonString = prefs.getString(_keyPrefix); | ||
| if (jsonString == null || jsonString.isEmpty) { | ||
| // Attempt migration from legacy unscoped key on first load | ||
| final legacyJsonString = prefs.getString(_keyPrefix); | ||
| prefs.remove(_keyPrefix); | ||
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | ||
| appLogger.info( | ||
| 'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor', | ||
| ); | ||
| await prefs.setString(keyFor, legacyJsonString); |
There was a problem hiding this comment.
loadCommunities() reads from _keyPrefix instead of the scoped keyFor, and the migration path reads/removes _keyPrefix again. After migrating, subsequent loads won't read the scoped key, so communities appear to disappear. Read from keyFor first, then fall back to legacy _keyPrefix for one-time migration.
| String? jsonString = prefs.getString(_keyPrefix); | |
| if (jsonString == null || jsonString.isEmpty) { | |
| // Attempt migration from legacy unscoped key on first load | |
| final legacyJsonString = prefs.getString(_keyPrefix); | |
| prefs.remove(_keyPrefix); | |
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | |
| appLogger.info( | |
| 'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor', | |
| ); | |
| await prefs.setString(keyFor, legacyJsonString); | |
| // First try to read from the scoped key | |
| String? jsonString = prefs.getString(keyFor); | |
| if (jsonString == null || jsonString.isEmpty) { | |
| // Attempt migration from legacy unscoped key on first load | |
| final legacyJsonString = prefs.getString(_keyPrefix); | |
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | |
| appLogger.info( | |
| 'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor', | |
| ); | |
| await prefs.setString(keyFor, legacyJsonString); | |
| prefs.remove(_keyPrefix); |
| String? jsonString = prefs.getString(_keyPrefix); | ||
| if (jsonString == null || jsonString.isEmpty) { | ||
| // Attempt migration from legacy unscoped key on first load | ||
| final legacyJsonString = prefs.getString(_keyPrefix); | ||
| prefs.remove(_keyPrefix); | ||
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { |
There was a problem hiding this comment.
loadChannelOrder() reads from _keyPrefix ("channel_order_") rather than the scoped keyFor, and the migration also reads/removes _keyPrefix. This both (1) prevents loading the scoped value stored under keyFor, and (2) won't migrate the real legacy key (previously channel_order). Read from keyFor first, and migrate from the old constant key name.
| String? jsonString = prefs.getString(_keyPrefix); | ||
| if (jsonString == null || jsonString.isEmpty) { | ||
| // Attempt migration from legacy unscoped key on first load | ||
| final legacyJsonString = prefs.getString(_keyPrefix); | ||
| prefs.remove(_keyPrefix); | ||
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | ||
| appLogger.info( | ||
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $key', | ||
| ); | ||
| await prefs.setString(key, legacyJsonString); |
There was a problem hiding this comment.
loadChannelMessages() fetches from prefs.getString(_keyPrefix) instead of prefs.getString(key) (scoped per channel). The migration path also reads/removes _keyPrefix rather than the per-channel legacy key (e.g. channel_messages_<index>). This means scoped messages will never load after migration. Read key first and migrate from the correct per-channel legacy key.
| String? jsonString = prefs.getString(_keyPrefix); | |
| if (jsonString == null || jsonString.isEmpty) { | |
| // Attempt migration from legacy unscoped key on first load | |
| final legacyJsonString = prefs.getString(_keyPrefix); | |
| prefs.remove(_keyPrefix); | |
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | |
| appLogger.info( | |
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $key', | |
| ); | |
| await prefs.setString(key, legacyJsonString); | |
| // First, try to load from the scoped key used by saveChannelMessages | |
| String? jsonString = prefs.getString(key); | |
| if (jsonString == null || jsonString.isEmpty) { | |
| // Attempt migration from legacy per-channel key on first load | |
| final String legacyKey = '$_keyPrefix$channelIndex'; | |
| final String? legacyJsonString = prefs.getString(legacyKey); | |
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | |
| appLogger.info( | |
| 'Migrating channel messages from legacy key $legacyKey to scoped key $key', | |
| ); | |
| await prefs.setString(key, legacyJsonString); | |
| await prefs.remove(legacyKey); |
| /// Clear messages for a specific channel | ||
| Future<void> clearChannelMessages(int channelIndex) async { | ||
| final prefs = PrefsManager.instance; | ||
| final key = '$_keyPrefix$channelIndex'; | ||
| final key = '$keyFor$channelIndex'; | ||
| await prefs.remove(key); | ||
| } |
There was a problem hiding this comment.
clearChannelMessages() does not guard against an unset publicKeyHex. With an empty publicKeyHex, $keyFor$channelIndex collapses to the legacy prefix and can remove another companion's data. Add the same publicKeyHex.isEmpty early-return guard used by the other methods in this store.
| String? jsonString = prefs.getString(_keyPrefix); | ||
| if (jsonString == null || jsonString.isEmpty) { | ||
| // Attempt migration from legacy unscoped key on first load | ||
| final legacyJsonString = prefs.getString(_keyPrefix); | ||
| prefs.remove(_keyPrefix); | ||
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | ||
| appLogger.info( | ||
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', | ||
| ); | ||
| await prefs.setString(keyFor, legacyJsonString); |
There was a problem hiding this comment.
loadContactUnreadCount() reads from _keyPrefix instead of the scoped keyFor, and the migration path also reads/removes _keyPrefix again. After the legacy key is removed, subsequent loads will never read the data written under keyFor, effectively losing unread state. Read from keyFor first, then fall back to the legacy _keyPrefix key for one-time migration.
| String? jsonString = prefs.getString(_keyPrefix); | |
| if (jsonString == null || jsonString.isEmpty) { | |
| // Attempt migration from legacy unscoped key on first load | |
| final legacyJsonString = prefs.getString(_keyPrefix); | |
| prefs.remove(_keyPrefix); | |
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | |
| appLogger.info( | |
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', | |
| ); | |
| await prefs.setString(keyFor, legacyJsonString); | |
| // Read from scoped key first | |
| String? jsonString = prefs.getString(keyFor); | |
| if (jsonString == null || jsonString.isEmpty) { | |
| // Attempt migration from legacy unscoped key on first load | |
| final legacyJsonString = prefs.getString(_keyPrefix); | |
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | |
| appLogger.info( | |
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', | |
| ); | |
| await prefs.setString(keyFor, legacyJsonString); | |
| await prefs.remove(_keyPrefix); |
| String? jsonString = prefs.getString(_keyPrefix); | ||
| if (jsonString == null || jsonString.isEmpty) { | ||
| // Attempt migration from legacy unscoped key on first load | ||
| final legacyJsonString = prefs.getString(_keyPrefix); | ||
| prefs.remove(_keyPrefix); | ||
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | ||
| appLogger.info( | ||
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', | ||
| ); | ||
| await prefs.setString(keyFor, legacyJsonString); |
There was a problem hiding this comment.
loadChannels() reads from _keyPrefix instead of the scoped keyFor, and the migration path reads/removes _keyPrefix again. This prevents loading channels stored under keyFor after the first migration. Read from keyFor first, then fall back to the legacy _keyPrefix key for one-time migration.
| String? jsonString = prefs.getString(_keyPrefix); | |
| if (jsonString == null || jsonString.isEmpty) { | |
| // Attempt migration from legacy unscoped key on first load | |
| final legacyJsonString = prefs.getString(_keyPrefix); | |
| prefs.remove(_keyPrefix); | |
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | |
| appLogger.info( | |
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', | |
| ); | |
| await prefs.setString(keyFor, legacyJsonString); | |
| String? jsonString = prefs.getString(keyFor); | |
| if (jsonString == null || jsonString.isEmpty) { | |
| // Attempt migration from legacy unscoped key on first load | |
| final legacyJsonString = prefs.getString(_keyPrefix); | |
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | |
| appLogger.info( | |
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', | |
| ); | |
| await prefs.setString(keyFor, legacyJsonString); | |
| prefs.remove(_keyPrefix); |
| /// Clear all channel messages | ||
| Future<void> clearAllChannelMessages() async { | ||
| final prefs = PrefsManager.instance; | ||
| final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix)); | ||
| final keys = prefs.getKeys().where((k) => k.startsWith(keyFor)); | ||
| for (var key in keys) { | ||
| await prefs.remove(key); |
There was a problem hiding this comment.
clearAllChannelMessages() now filters keys by keyFor. If publicKeyHex is unset, keyFor collapses to the legacy prefix and this method will delete all channel message keys across companions. Add a publicKeyHex.isEmpty guard (consistent with save/load) before enumerating/removing keys.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| prefs.remove(_keyPrefix); | ||
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | ||
| appLogger.info( | ||
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', |
There was a problem hiding this comment.
The migration log message says "Migrating channel messages" but this store migrates contact groups; the message should reflect the data type being migrated.
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', | |
| 'Migrating contact groups from legacy key $_keyPrefix to scoped key $keyFor', |
| prefs.remove(_keyPrefix); | ||
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | ||
| appLogger.info( | ||
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', |
There was a problem hiding this comment.
The migration log message says "Migrating channel messages" but this store migrates channel metadata (channels list). Consider updating it to avoid confusing logs.
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', | |
| 'Migrating channel metadata (channels list) from legacy key $_keyPrefix to scoped key $keyFor', |
| prefs.remove(_keyPrefix); | ||
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | ||
| appLogger.info( | ||
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', |
There was a problem hiding this comment.
The migration log message refers to "channel messages", but this store migrates unread counts. Updating the log message will make troubleshooting and log filtering accurate.
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', | |
| 'Migrating unread counts from legacy key $_keyPrefix to scoped key $keyFor', |
| String? jsonString = prefs.getString(_keyPrefix); | ||
| if (jsonString == null || jsonString.isEmpty) { | ||
| // Attempt migration from legacy unscoped key on first load | ||
| final legacyJsonString = prefs.getString(_keyPrefix); | ||
| prefs.remove(_keyPrefix); | ||
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | ||
| appLogger.info( | ||
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', | ||
| ); | ||
| await prefs.setString(keyFor, legacyJsonString); |
There was a problem hiding this comment.
loadGroups() reads from _keyPrefix (legacy/unscoped) but saveGroups() writes to keyFor (scoped). This means groups won’t load after the first scoped save. Read from keyFor first, and migrate from _keyPrefix only when keyFor is missing.
| String? jsonString = prefs.getString(_keyPrefix); | |
| if (jsonString == null || jsonString.isEmpty) { | |
| // Attempt migration from legacy unscoped key on first load | |
| final legacyJsonString = prefs.getString(_keyPrefix); | |
| prefs.remove(_keyPrefix); | |
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | |
| appLogger.info( | |
| 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', | |
| ); | |
| await prefs.setString(keyFor, legacyJsonString); | |
| String? jsonString = prefs.getString(keyFor); | |
| if (jsonString == null || jsonString.isEmpty) { | |
| // Attempt migration from legacy unscoped key on first load | |
| final legacyJsonString = prefs.getString(_keyPrefix); | |
| if (legacyJsonString != null && legacyJsonString.isNotEmpty) { | |
| appLogger.info( | |
| 'Migrating contact groups from legacy key $_keyPrefix to scoped key $keyFor', | |
| ); | |
| await prefs.setString(keyFor, legacyJsonString); | |
| await prefs.remove(_keyPrefix); |
This adds support to connect to multiple companions without having the data bleed over into each other.
Adds 5 bytes of the companion's public key to the prefix of the data stores.