-
Notifications
You must be signed in to change notification settings - Fork 28
fix: stop one chat conversation from showing as two chat rooms #709
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -146,13 +146,61 @@ class SessionNotifier extends StateNotifier<List<Session>> { | |
| } | ||
|
|
||
| void _emitState() { | ||
| // A session is reachable through three maps and only `_sessions` is keyed | ||
| // by orderId, so the same order can otherwise surface twice (e.g. a | ||
| // request-id session whose orderId was assigned by Mostro alongside the | ||
| // persisted session the restore flow rebuilt for that same order). Every | ||
| // consumer derived from this state — most visibly the chat list, which | ||
| // renders one row per session — would then show the order twice. | ||
| // `_sessions` holds the persisted session, so it wins. | ||
| final combined = <Session>[]; | ||
| combined.addAll(_sessions.values); | ||
| combined.addAll(_requestIdToSession.values); | ||
| combined.addAll(_pendingChildSessions.values); | ||
| final claimedOrderIds = <String>{}; | ||
| for (final session in [ | ||
| ..._sessions.values, | ||
| ..._requestIdToSession.values, | ||
| ..._pendingChildSessions.values, | ||
| ]) { | ||
| final orderId = session.orderId; | ||
| if (orderId != null && !claimedOrderIds.add(orderId)) continue; | ||
| combined.add(session); | ||
| } | ||
| state = combined; | ||
| } | ||
|
|
||
| /// Drops every *other* in-memory session that carries [orderId] now that | ||
| /// [owner] is the session of record for it. Identity is not enough: the | ||
| /// restore flow rebuilds a brand new [Session] for an order that a pending | ||
| /// request-id or child session may already point at. | ||
| void _claimOrderId(String orderId, Session owner) { | ||
| bool isStale(Session session) => | ||
| !identical(session, owner) && session.orderId == orderId; | ||
|
|
||
| void logEviction(Session session) { | ||
| // A differing trade key means the evicted session held key material no | ||
| // other map can resolve any more, so surface it rather than dropping it | ||
| // silently. | ||
| if (session.tradeKey.public != owner.tradeKey.public) { | ||
| logger.w( | ||
| 'Evicting session for order $orderId with a different trade key ' | ||
| '(${session.tradeKey.public}); it is no longer resolvable.', | ||
| ); | ||
| } else { | ||
| logger.d('Evicted a duplicate in-memory session for order $orderId'); | ||
| } | ||
| } | ||
|
|
||
| _requestIdToSession.removeWhere((_, session) { | ||
| if (!isStale(session)) return false; | ||
| logEviction(session); | ||
| return true; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: sed -n '170,225p' lib/shared/notifiers/session_notifier.dart
sed -n '225,320p' lib/shared/notifiers/session_notifier.dart
rg -n "_evictSessionKeyMaterial|_claimOrderId|removeWhere|_requestIdToSession|_pendingChildSessions" lib/shared/notifiers/session_notifier.dartRepository: MostroP2P/mobile Length of output: 7589 🏁 Script executed: #!/bin/sh
sed -n '170,225p' lib/shared/notifiers/session_notifier.dart
sed -n '225,320p' lib/shared/notifiers/session_notifier.dart
rg -n "_evictSessionKeyMaterial|_claimOrderId|removeWhere|_requestIdToSession|_pendingChildSessions" lib/shared/notifiers/session_notifier.dartRepository: MostroP2P/mobile Length of output: 7589 🏁 Script executed: cat -n lib/shared/notifiers/session_notifier.dart | sed -n '180,220p'
cat -n lib/shared/notifiers/session_notifier.dart | sed -n '235,275p'
cat -n lib/shared/notifiers/session_notifier.dart | sed -n '285,315p'Repository: MostroP2P/mobile Length of output: 4687 🏁 Script executed: rg -n "conversation.?key|evictConversationKeysFor|class NostrUtils" libRepository: MostroP2P/mobile Length of output: 1516 🏁 Script executed: cat -n lib/shared/utils/nostr_utils.dart | sed -n '330,395p'Repository: MostroP2P/mobile Length of output: 3173 Sensitive Data Exposure (CWE-226) Exploitability: Difficult Evict key material for stale sessions. Call 🤖 Prompt for AI Agents |
||
| }); | ||
| _pendingChildSessions.removeWhere((_, session) { | ||
| if (!isStale(session)) return false; | ||
| logEviction(session); | ||
| return true; | ||
| }); | ||
| } | ||
|
|
||
| void _scheduleCleanup() { | ||
| _cleanupTimer?.cancel(); | ||
| _cleanupTimer = Timer.periodic( | ||
|
|
@@ -221,6 +269,7 @@ class SessionNotifier extends StateNotifier<List<Session>> { | |
|
|
||
| if (orderId != null) { | ||
| _sessions[orderId] = session; | ||
| _claimOrderId(orderId, session); | ||
| } else if (requestId != null) { | ||
| _requestIdToSession[requestId] = session; | ||
| } | ||
|
|
@@ -233,6 +282,7 @@ class SessionNotifier extends StateNotifier<List<Session>> { | |
| _sessions[session.orderId!] = session; | ||
| _requestIdToSession.removeWhere((_, value) => identical(value, session)); | ||
| _pendingChildSessions.remove(session.tradeKey.public); | ||
| _claimOrderId(session.orderId!, session); | ||
| await _storage.putSession(session); | ||
| _emitState(); | ||
|
|
||
|
|
@@ -250,6 +300,7 @@ class SessionNotifier extends StateNotifier<List<Session>> { | |
| if (orderId == null) return; | ||
| _sessions[orderId] = session; | ||
| _requestIdToSession.removeWhere((_, value) => identical(value, session)); | ||
| _claimOrderId(orderId, session); | ||
| _emitState(); | ||
| } | ||
|
|
||
|
|
@@ -410,6 +461,7 @@ class SessionNotifier extends StateNotifier<List<Session>> { | |
|
|
||
| session.orderId = childOrderId; | ||
| _sessions[childOrderId] = session; | ||
| _claimOrderId(childOrderId, session); | ||
| await _storage.putSession(session); | ||
| _emitState(); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| import 'package:dart_nostr/dart_nostr.dart'; | ||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||
| import 'package:flutter_test/flutter_test.dart'; | ||
| import 'package:mostro_mobile/data/models/chat_room.dart'; | ||
| import 'package:mostro_mobile/data/models/peer.dart'; | ||
| import 'package:mostro_mobile/data/models/session.dart'; | ||
| import 'package:mostro_mobile/features/chat/notifiers/chat_room_notifier.dart'; | ||
| import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; | ||
| import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; | ||
| import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; | ||
| import 'package:shared_preferences/shared_preferences.dart'; | ||
|
|
||
| import '../../mocks.mocks.dart'; | ||
|
|
||
| /// `KeyManager.getNextKeyIndex` used to hand out an already-reserved trade | ||
| /// key index, so two sessions could share a trade key. With a common | ||
| /// counterparty they also share the ECDH shared key the chat envelope keys | ||
| /// are derived from, and both chat rooms then accept the very same messages. | ||
| /// Sessions created before that fix are still on disk, so the list collapses | ||
| /// them into a single row. | ||
| void main() { | ||
| const olderOrderId = 'order-older'; | ||
| const newerOrderId = 'order-newer'; | ||
| // A real curve point: the shared key is a genuine ECDH computation. | ||
| final peerPubkey = NostrKeyPairs( | ||
| private: | ||
| '5566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344', | ||
| ).public; | ||
| // Both sessions carry this trade key, which is exactly the collision. | ||
| const sharedTradeKey = | ||
| 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; | ||
|
|
||
| late ProviderContainer container; | ||
| late Map<String, List<NostrEvent>> messagesByOrderId; | ||
|
|
||
| NostrEvent message(String id) => NostrEvent( | ||
| id: id, | ||
| kind: 14, | ||
| content: 'hola', | ||
| sig: '', | ||
| pubkey: 'peer-pubkey', | ||
| createdAt: DateTime.fromMillisecondsSinceEpoch(1000), | ||
| tags: const [], | ||
| ); | ||
|
|
||
| Session session({ | ||
| required String orderId, | ||
| required DateTime startTime, | ||
| String tradeKeyPrivate = sharedTradeKey, | ||
| }) { | ||
| final s = Session( | ||
| masterKey: NostrKeyPairs( | ||
| private: | ||
| '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'), | ||
| tradeKey: NostrKeyPairs(private: tradeKeyPrivate), | ||
| keyIndex: 0, | ||
| fullPrivacy: false, | ||
| startTime: startTime, | ||
| peer: Peer(publicKey: peerPubkey), | ||
| ); | ||
| s.orderId = orderId; | ||
| return s; | ||
| } | ||
|
|
||
| void arrange(List<Session> sessions) { | ||
| container = ProviderContainer(overrides: [ | ||
| sessionNotifierProvider.overrideWith((ref) { | ||
| final notifier = _FakeSessionNotifier(ref); | ||
| notifier.emit(sessions); | ||
| return notifier; | ||
| }), | ||
| chatRoomsProvider.overrideWith((ref, id) => ChatRoomNotifier( | ||
| ChatRoom(orderId: id, messages: messagesByOrderId[id] ?? <NostrEvent>[]), | ||
| id, | ||
| ref, | ||
| )), | ||
| ]); | ||
| addTearDown(container.dispose); | ||
| } | ||
|
|
||
| setUp(() { | ||
| SharedPreferences.setMockInitialValues({}); | ||
| messagesByOrderId = {}; | ||
| }); | ||
|
|
||
| test('two sessions sharing a conversation key render a single row', () { | ||
| // Arrange: both rooms hold the conversation (as they do while the app is | ||
| // running and both notifiers accept the same live envelopes). | ||
| messagesByOrderId = { | ||
| olderOrderId: [message('m1')], | ||
| newerOrderId: [message('m1')], | ||
| }; | ||
| arrange([ | ||
| session( | ||
| orderId: olderOrderId, | ||
| startTime: DateTime.now().subtract(const Duration(minutes: 30)), | ||
| ), | ||
| session(orderId: newerOrderId, startTime: DateTime.now()), | ||
| ]); | ||
|
|
||
| // Act | ||
| final chats = container.read(chatRoomsNotifierProvider); | ||
|
|
||
| // Assert: the newest session wins, and the conversation is shown once. | ||
| expect(chats.map((c) => c.orderId), [newerOrderId]); | ||
| }); | ||
|
|
||
| test( | ||
| 'the conversation survives when only the older room holds the history', | ||
| () { | ||
| // Arrange: after a restart only one room reloads the history — envelopes | ||
| // are stored globally once, under whichever orderId first handled them | ||
| // (ChatRoomNotifier._onChatEvent), and _loadHistoricalMessages filters on | ||
| // that orderId. Here the newer session's room comes up empty. | ||
| messagesByOrderId = { | ||
| olderOrderId: [message('m1')], | ||
| newerOrderId: <NostrEvent>[], | ||
| }; | ||
| arrange([ | ||
| session( | ||
| orderId: olderOrderId, | ||
| startTime: DateTime.now().subtract(const Duration(minutes: 30)), | ||
| ), | ||
| session(orderId: newerOrderId, startTime: DateTime.now()), | ||
| ]); | ||
|
|
||
| // Act | ||
| final chats = container.read(chatRoomsNotifierProvider); | ||
|
|
||
| // Assert: claiming the conversation for the empty newer room would drop | ||
| // the older one too and make the chat vanish entirely. | ||
| expect(chats.map((c) => c.orderId), [olderOrderId]); | ||
| }); | ||
|
|
||
| test('distinct conversations are both kept', () { | ||
| // Arrange: different trade keys, so different ECDH shared keys. | ||
| messagesByOrderId = { | ||
| olderOrderId: [message('m1')], | ||
| newerOrderId: [message('m2')], | ||
| }; | ||
| arrange([ | ||
| session( | ||
| orderId: olderOrderId, | ||
| startTime: DateTime.now().subtract(const Duration(minutes: 30)), | ||
| ), | ||
| session( | ||
| orderId: newerOrderId, | ||
| startTime: DateTime.now(), | ||
| tradeKeyPrivate: | ||
| '0fedcba9876543210fedcba9876543210fedcba9876543210fedcba987654321', | ||
| ), | ||
| ]); | ||
|
|
||
| // Act | ||
| final chats = container.read(chatRoomsNotifierProvider); | ||
|
|
||
| // Assert | ||
| expect(chats.map((c) => c.orderId), [newerOrderId, olderOrderId]); | ||
| }); | ||
| } | ||
|
|
||
| /// Session list the provider can read without touching storage. | ||
| class _FakeSessionNotifier extends SessionNotifier { | ||
| _FakeSessionNotifier(Ref ref) | ||
| : super(ref, MockSessionStorage(), MockSettings()) { | ||
| state = const []; | ||
| } | ||
|
|
||
| void emit(List<Session> sessions) => state = sessions; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For legacy colliding sessions after an app restart, the newest session can have an empty room while the older session owns the persisted history: each envelope is stored globally only once under whichever
orderIdfirst handles it (chat_room_notifier.dart:255-261). This code adds the newest session's key toseenConversationsbefore checking whether its room has messages, so the empty room is discarded and the older room containing the history is then skipped, making the entire conversation disappear from the chat list. Read the room and confirm it is nonempty before claiming the conversation key, or select the nonempty candidate when collapsing duplicates.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Valid finding, fixed in bf1f354.
Confirmed the mechanism: envelopes are stored globally once, keyed by outer id and tagged with whichever
orderIdfirst handled them (chat_room_notifier.dart:255-261), and_loadHistoricalMessagesreloads by thatorderId. So after a restart only one of two colliding rooms holds the history, and it can be the older session's. Claiming the conversation key before the emptiness check meant the newest (empty) room consumed the claim, was then dropped as empty, and the room actually holding the messages was skipped as a duplicate — hiding the conversation entirely, which is worse than the duplicate the dedup was added for._chatsForSessionsnow resolves the room and requires it non-empty before claiming the conversation key, so the empty candidate is passed over and the one with the history wins.Added
test/features/chat/chat_rooms_notifier_dedup_test.dartcovering exactly this case (the conversation survives when only the older room holds the history), plus collapsing when both rooms hold the conversation and keeping genuinely distinct conversations separate. Verified the new test is a real regression test by reverting the reorder — only that case goes RED (Expected: ['order-older'] Actual: []).