Skip to content

fix: resolve unknown sender in group notifications and infinite loading spinner#3062

Open
blue-archon wants to merge 5 commits into
BlueBubblesApp:developmentfrom
blue-archon:pr-bugfix
Open

fix: resolve unknown sender in group notifications and infinite loading spinner#3062
blue-archon wants to merge 5 commits into
BlueBubblesApp:developmentfrom
blue-archon:pr-bugfix

Conversation

@blue-archon

Copy link
Copy Markdown
Contributor

Problem

Two notification pipeline bugs present in v2.0.0+85:

  1. Unknown sender in group chat notifications — a second message arriving rapidly in a group chat showed "Unknown" as the sender name
  2. Infinite loading spinner when opening a conversation from a notification tap

Root causes and fixes

Unknown sender: The handle lookup on Message.getHandle() fell through to a DB query that raced with ObjectBox relation loading. Fixed by checking the in-memory transient handle field (populated by addMessageToChat before the DB put) before issuing the query.

Also adds lib/utils/deep_map_normalize.dart — a utility to fix LinkedHashMap<Object,Object> type corruption on nested server payload maps after cross-isolate transfer. This affects attributedBody, messageSummaryInfo, payloadData, and serverPayload deserialization on both FCM and UnifiedPush background paths.

Infinite loading spinner: getMessagesAsync filtered all messages when chatStyle was not yet known for a group chat (single-member participant list). Fixed by threading chatStyle through the interface so the group detection gate has the correct value, and setting noMoreMessages on the first empty-page response so the spinner clears.

What changed

  • lib/utils/deep_map_normalize.dart — new utility
  • lib/database/global/attributedBody, messageSummaryInfo, payloadData, serverPayload normalization
  • lib/database/io/message, handle, attachment, chat
  • lib/app/state/message_state.dart
  • lib/app/layouts/conversation_view/pages/messages_view.dart
  • lib/services/backend/actions/chat_actions.dart, sync_actions.dart
  • lib/services/backend/interfaces/chat_interface.dart
  • lib/services/backend/incoming_message_handler.dart

Comment thread lib/services/backend/actions/chat_actions.dart Outdated
@blue-archon

Copy link
Copy Markdown
Contributor Author

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.

@zlshames

zlshames commented Jul 7, 2026

Copy link
Copy Markdown
Member

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.

@blue-archon

Copy link
Copy Markdown
Contributor Author

Pushed a small follow-up to this branch (b3e86cd82) that hardens the normalizer this PR adds.

During testing we hit a crash coming out of Chat.fromMap / Attachment.fromMap on live socket new/updated-message events: type 'Null' is not a subtype of type 'Object' in type cast. The cause is the .cast<String, Object>() at the call sites. On a JSON-decoded payload with a genuinely null field, the resulting CastMap re-casts that value to the non-nullable Object on every access, so both the jsonEncode fast path and the raw.entries fallback throw before the recursive normalize ever runs. One null field aborts the whole parse and drops that chat/attachment for the event. Incremental sync backfills it subsequently, but the parse shouldn't fail in the first place.

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.

@zlshames

zlshames commented Jul 13, 2026

Copy link
Copy Markdown
Member

For the most part, i think this looks good. The main change that concerns me is in _hydrateChat. Now, any group chat automatically has their full participant list re-fetched for any incoming message (not from me). I don't think this is scalable, and I don't love that as the solution for whatever problem you're solving.

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:

  1. chat_actions.dart's new +/- prefix fallback (addr.startsWith('+') ? addr.substring(1) : '+$addr') assumes a phone-number-shaped address; applied to an email address it produces a nonsensical +user@example.com query. Harmless, but maybe just a little bit sloppy. Maybe guard it with a quick @ check for emails.
  2. message_summary_info.dart has two now-redundant ! operators flagged by the analyzer.

Also, there are some conflicts now with the development branch

@blue-archon

Copy link
Copy Markdown
Contributor Author

Reworked per your review:

  • _hydrateChat no longer re-fetches the participant list on every incoming group message — only when the sender's handle isn't already resolvable locally. Keeps the linking fix, drops the per-message round-trip.
  • Guarded the +/- fallback with an @ check so it skips email addresses.
  • Removed the two redundant ! in message_summary_info.dart.
  • Rebased onto current development to clear the conflicts.

Also folded in a related fix I hit while validating the above: the participant-add de-duped only on originalROWID, so a sender handle with a null/mismatched originalROWID could add the same number to a chat twice (a phantom extra group member). It now de-dupes on address + service and collapses any pre-existing duplicate.

blue-archon added a commit to blue-archon/bluebubbles-app that referenced this pull request Jul 15, 2026
… 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 zlshames left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.dart adds a private _chatIsGroup(Chat chat) (style == 43 || handles.length > 1 || participants.length > 1) that duplicates Chat.isGroup (chat.dart:768, style == 43 || handles.length > 1) with a slightly different definition (adds the participants.length > 1 check). Reuse chat.isGroup instead.
  • deepNormalizeJson's "fast path" does a full jsonEncode + jsonDecode round-trip, and this is invoked again at every nesting level by the various fromMap/fromJson factories (each calls asStringDynamicMapRequired on 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.dart exposes normalizeMethodChannelArguments which 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.

…ng spinner

Bug 1: second rapid message in a group chat showed "Unknown" as sender.
Root cause: handle lookup on the Message object fell through to a DB query
that raced with ObjectBox relation loading; the handle arrived after the
notification was built. Fix: check the transient handle field first
(populated by addMessageToChat before the DB put) before issuing the query.
Also normalize all nested server payload maps with a shared deep_map_normalize
utility so that dynamic fields (attributedBody, messageSummaryInfo, etc.)
never arrive as LinkedHashMap<Object,Object> after cross-isolate transfer.

Bug 2: opening a conversation from a notification left an infinite
"Loading more messages…" spinner. Root cause: getMessagesAsync filtered
all messages when chat style was unknown (single-member participant list
on a group chat). Fix: gate the style-43/participant-length group check
on chatStyle so messages are never silently dropped, and set noMoreMessages
on the first empty page response so the spinner clears.
deepNormalizeJson's fallback rebuilt the map via raw.entries, which throws on a
CastMap<String, Object>: the call sites pass `.cast<String, Object>()` over a
JSON-decoded payload that legitimately holds null fields, and reading a null
through the non-nullable Object cast raises "type 'Null' is not a subtype of
type 'Object'". The jsonEncode fast path above fails the same way, so a single
null field aborted the whole Chat/Attachment parse and dropped the event.

Rebuild key-by-key with a guarded per-value lookup so a null can't blow up the
normalize.
- deepNormalizeJson: rebuild maps in a single guarded pass and return strings
  untouched, so numeric/bool-like message text ("1234", "true") is no longer
  reinterpreted into an int/bool and rejected by AttributedBody.string. Also
  drops the jsonEncode/jsonDecode round-trip that ran at every nesting level.
- chat_actions: drop the handle-link branch that only ran for fromMe messages
  (handle == null and not hasSenderHandle), which could mislink the recipient
  as the sender.
- chat_actions: drop the speculative +/- prefix fallback (no evidence the bare,
  no-plus address form actually occurs).
- incoming_message_handler: reuse Chat.isGroup instead of the near-duplicate
  _chatIsGroup helper.
- deep_map_normalize: remove the unused normalizeMethodChannelArguments.
@blue-archon

Copy link
Copy Markdown
Contributor Author

Addressed as follows:

  1. deepNormalizeJson: It no longer reinterprets string values as JSON. It does a single guarded pass that rebuilds only Maps and Lists and returns every scalar (including Strings) untouched, so a numeric or bool-like body like "1234" stays a String. That also drops the jsonEncode/jsonDecode round-trip you flagged, so the per-nesting-level re-encoding is gone. The CastMap null-field guard is preserved.
  2. fromMe branch: removed it. Once you require hasSenderHandle (or !isFromMe) on that branch, its condition is only reachable when isFromMe is true, so guarding it makes it dead. Removing it keeps the recipient from being linked as the sender.
  3. _chatIsGroup: switched to chat.isGroup.
  4. normalizeMethodChannelArguments: it was dead (no callers), removed.
  5. +/- prefix fallback: I couldn't reproduce a bare no-plus entry, so dropped the fallback for now. If it does turn out to occur can re-assess

@zlshames

Copy link
Copy Markdown
Member

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 hasSenderHandle/isFromMe check. If a fromMe message carries a non-zero handleId (the exact historical failure mode called out in the comment right above hasSenderHandle), and that ID happens to match a participant already linked to the chat (in a 1:1, that's the recipient), handleToLink gets set here and is later assigned via dbMessage.handleRelation.target = handleToLink (line ~378). That mislinks the recipient as the sender on an outgoing message. The exact bug the guard exists to prevent, just via a second code path that survived two rounds of review untouched.

@blue-archon

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants