fix: don't hide group messages when the participant list is stale - #3062
fix: don't hide group messages when the participant list is stale#3062blue-archon wants to merge 1 commit into
Conversation
|
I can't reproduce the original rows anymore so I can't say with certainty whether the bare form came from chat.db itself or something created locally through another path. The scenario was two rapid messages from the same sender, the second resolved to a handle the exact lookup missed, and the notification showed Unknown, and the fallback was written against that incident. It only runs when the exact address+service lookup has already missed. If the bare form never occurs it never matches and behavior is identical to today, just one extra indexed query on the miss path. If it does occur from whatever source (server payload variance, SMS relay entries, a locally created row), it links to the canonical handle instead of creating an orphaned duplicate. So worst case it's inert, best case it prevents the Unknown sender case. |
i wonder if this was moreso just a race case between when the contact relationship was loaded in vs. when it wasn't. But interesting anecdote. Not sure if i want to approve all of the changes just yet. Or maybe there's a slightly better way to implement the changes that is resolving it at the root. I'll have to investigate some more. |
|
Pushed a small follow-up to this branch ( During testing we hit a crash coming out of The fix rebuilds the fallback map key by key with a guarded per-value read, so a null can't take down the normalize. Since it's the exact path this PR introduces, it seemed to belong here rather than as a separate change. It showed up a handful of times over a couple days of normal use on desktop and Android before the changes. |
|
For the most part, i think this looks good. The main change that concerns me is in That's the main thing I'd revert or change. I just dont think it makes sense right now. At some point it might be, but right now, I'm not so sure. There are also a few small things I might want to change a little bit:
Also, there are some conflicts now with the development branch |
|
Reworked per your review:
Also folded in a related fix I hit while validating the above: the participant-add de-duped only on |
… email guard, lint Per zlshames' review on BlueBubblesApp#3062: - _hydrateChat no longer re-fetches the full participant list on every incoming group message; only re-fetch when the sender's handle isn't already resolvable locally (skips the per-message server round-trip that doesn't scale) - guard the +/- prefix handle fallback with an @ check so it doesn't build a nonsensical +user@example.com query for email addresses - drop two redundant ! operators in message_summary_info.dart Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
zlshames
left a comment
There was a problem hiding this comment.
Here are some additional things to resolve:
1. deepNormalizeJson can silently corrupt legitimate string content (lib/utils/deep_map_normalize.dart:848-889)
The fallback path (triggered exactly when jsonEncode(raw) throws. For instance, the CastMap-with-null case this utility was written to handle) rebuilds the map key-by-key and calls deepNormalizeJson(value) on every value, including plain-text strings. For a String value, the function unconditionally attempts jsonDecode(raw):
if (raw is String) {
try {
return deepNormalizeJson(jsonDecode(raw));
} catch (_) {
return raw;
}
}This is applied to fields like AttributedBody.string, which is the message text. Any message body that happens to be valid JSON on its own (a purely numeric text like "1234", or literally "true"/"null"/"false") will be silently converted from a String to an int/bool/null. Since AttributedBody.string is typed final String string;, assigning a non-string value via json["string"] ?? "" will throw a runtime TypeError on that message. For example, the exact null-containing-map scenario this PR targets is also the scenario that can now crash message parsing for ordinary numeric-text messages. Worth scoping the JSON-string-reinterpretation to fields that are actually expected to be JSON-encoded strings, rather than applying it to every string value found while walking a map.
2. Reintroduces the "mislinked recipient as sender" bug for fromMe messages (lib/services/backend/actions/chat_actions.dart, new else if branch)
The existing code deliberately guards handle resolution with:
final bool hasSenderHandle = !(inputMessage.isFromMe ?? false) && (inputMessage.handleId ?? 0) != 0;
if (inputMessage.handle == null && hasSenderHandle) { ... }
else if (inputMessage.handle != null) { ... }with a comment explaining hasSenderHandle exists specifically because "in 1:1 chats, [it] mislinked the RECIPIENT's handle as the message's sender." This PR adds a third branch:
} else if (inputMessage.handleId != null && inputMessage.handleId! > 0) {
final handleQuery = handleBox.query(Handle_.originalROWID.equals(inputMessage.handleId!)).build();
...
handleToLink = handleQuery.findFirst();
}This branch is reachable when inputMessage.handle == null and hasSenderHandle is false, which includes the case isFromMe == true with a non-zero handleId. It performs the handle lookup without checking isFromMe, so an outgoing message that happens to carry a non-zero handleId (plausible, since that's precisely the historical failure mode called out in the adjacent comment) can get handleToLink set and later assigned via dbMessage.handleRelation.target = handleToLink, reintroducing the bug the hasSenderHandle guard exists to prevent. This branch should also require hasSenderHandle (or explicitly !inputMessage.isFromMe!).
Other minor things I think makes sense to change:
incoming_message_handler.dartadds a private_chatIsGroup(Chat chat)(style == 43 || handles.length > 1 || participants.length > 1) that duplicatesChat.isGroup(chat.dart:768,style == 43 || handles.length > 1) with a slightly different definition (adds theparticipants.length > 1check). Reusechat.isGroupinstead.deepNormalizeJson's "fast path" does a fulljsonEncode+jsonDecoderound-trip, and this is invoked again at every nesting level by the variousfromMap/fromJsonfactories (each callsasStringDynamicMapRequiredon its own sub-map). For deeply nested structures (e.g.messageSummaryInfo.editedContent.values) this is quadratic-ish rework, though likely immaterial given payload sizes.deep_map_normalize.dartexposesnormalizeMethodChannelArgumentswhich appears unused in this diff. Can you confirm it's wired up somewhere (Android method channel handlers) or drop it if it's dead code introduced ahead of need.
I am a bit concerned about the performance impact of the json encoding round-trip. The object sizes usually aren't too large, but it still could add jank if done alongside any UI rendering.
|
Addressed as follows:
|
|
Looks like we missed one thing: if (dbChat != null) {
inputMessage.chat.target = dbChat;
// Fallback: resolve sender against this chat's linked participants
if (handleToLink == null && inputMessage.handleId != null && inputMessage.handleId! > 0) {
handleToLink = List<Handle>.from(dbChat.handles).firstWhereOrNull((h) => h.originalROWID == inputMessage.handleId);
}
...
}This runs unconditionally with no |
|
Added the same !isFromMe guard to the dbChat participant fallback so it matches the hasSenderHandle branch above; a from-me message with a non-zero handleId now skips it instead of resolving to the recipient. |
|
Reduced this to just the group-message-visibility fix. |
getMessagesAsync filtered out any message whose sender did not match a known participant — correct for 1:1 chats, but in a group it drops valid messages whenever the local participant list is stale, which surfaces as the infinite loading spinner / missing group messages (upstream BlueBubblesApp#3062). Guard the filter to 1:1 chats only (chatStyle == 43 => iMessage group; participants.length > 1 as an SMS-group fallback), threading chatStyle through Chat -> ChatInterface -> ChatActions. v2.0.0 already carries the unknown-sender half of BlueBubblesApp#3062 (hasSenderHandle + sole-participant fallback), so only this half needed re-applying. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getMessagesAsync filtered out any message whose sender did not match a known participant — correct for 1:1 chats, but in a group it drops valid messages whenever the local participant list is stale, which surfaces as the infinite loading spinner / missing group messages (upstream BlueBubblesApp#3062). Guard the filter to 1:1 chats only (chatStyle == 43 => iMessage group; participants.length > 1 as an SMS-group fallback), threading chatStyle through Chat -> ChatInterface -> ChatActions. v2.0.0 already carries the unknown-sender half of BlueBubblesApp#3062 (hasSenderHandle + sole-participant fallback), so only this half needed re-applying. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getMessagesAsync filtered out any message whose sender did not match a known participant — correct for 1:1 chats, but in a group it drops valid messages whenever the local participant list is stale, which surfaces as the infinite loading spinner / missing group messages (upstream BlueBubblesApp#3062). Guard the filter to 1:1 chats only (chatStyle == 43 => iMessage group; participants.length > 1 as an SMS-group fallback), threading chatStyle through Chat -> ChatInterface -> ChatActions. v2.0.0 already carries the unknown-sender half of BlueBubblesApp#3062 (hasSenderHandle + sole-participant fallback), so only this half needed re-applying. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getMessagesAsync drops any message whose sender does not match a known participant. That is correct for a 1:1 chat, but in a group it removes valid messages whenever the local participant list is stale (for example mid-sync), which shows up as an infinite loading spinner or missing messages. Guard the filter to 1:1 chats only: chatStyle == 43 identifies an iMessage group, with participants.length > 1 as a fallback for SMS groups. chatStyle is threaded from Chat through ChatInterface to ChatActions.
getMessagesAsync filtered out any message whose sender did not match a known participant — correct for 1:1 chats, but in a group it drops valid messages whenever the local participant list is stale, which surfaces as the infinite loading spinner / missing group messages (upstream BlueBubblesApp#3062). Guard the filter to 1:1 chats only (chatStyle == 43 => iMessage group; participants.length > 1 as an SMS-group fallback), threading chatStyle through Chat -> ChatInterface -> ChatActions. v2.0.0 already carries the unknown-sender half of BlueBubblesApp#3062 (hasSenderHandle + sole-participant fallback), so only this half needed re-applying. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Rebased onto v2.1.0. I believe the earlier review points are addressed: shrunk to just the |
getMessagesAsync filtered out any message whose sender did not match a known participant — correct for 1:1 chats, but in a group it drops valid messages whenever the local participant list is stale, which surfaces as the infinite loading spinner / missing group messages (upstream BlueBubblesApp#3062). Guard the filter to 1:1 chats only (chatStyle == 43 => iMessage group; participants.length > 1 as an SMS-group fallback), threading chatStyle through Chat -> ChatInterface -> ChatActions. v2.0.0 already carries the unknown-sender half of BlueBubblesApp#3062 (hasSenderHandle + sole-participant fallback), so only this half needed re-applying. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Problem
getMessagesAsyncdrops any message whose sender does not match a known participant. That is correct for a 1:1 chat, but in a group it removes valid messages whenever the local participant list is stale (for example mid-sync), which shows up as an infinite loading spinner or missing messages.Fix
Guard the filter to 1:1 chats only:
chatStyle == 43identifies an iMessage group, withparticipants.length > 1as a fallback for SMS groups.chatStyleis threaded fromChatthroughChatInterfacetoChatActions.