Skip to content

fix(inbox): correctness and cross-platform parity fixes - #799

Open
mahmoud-elmorabea wants to merge 7 commits into
feat/overlay-inboxfrom
inbox-review-fixes
Open

fix(inbox): correctness and cross-platform parity fixes#799
mahmoud-elmorabea wants to merge 7 commits into
feat/overlay-inboxfrom
inbox-review-fixes

Conversation

@mahmoud-elmorabea

@mahmoud-elmorabea mahmoud-elmorabea commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Addresses the correctness findings from the cross-platform review of the inbox overlay work. Paired with the equivalent iOS PR so both SDKs behave identically.

  • An absent or unparseable X-CIO-Inbox-Enabled header no longer hides the inbox. 304s and error responses routinely omit it, so the previous coercion to false made the bell disappear until the next clean 200.
  • The once-per-session revalidation gate reopens on a new store session id, so a logout can't serve the previous user's templates/branding.
  • Expiry is now inclusive (expiry <= now), matching iOS.
  • fromCache is removed from InboxVisibility/InboxFetchOutcome; nothing consumed it and it was miscomputed when one asset came from cache and the other was refetched.
  • The host messageOpened callback reports the post-action opened state.
  • Removed the unnecessary @OptIn(InternalCustomerIOApi) from both samples (verified they compile without it).
  • Deleted the stale MessagingInboxModuleTest placeholder.

Verified locally: messaginginapp + messaginginbox unit tests, ktlint, apiCheck, and both sample apps compile.

🤖 Generated with Claude Code


Note

Medium Risk
Touches server-driven enablement, per-session asset caching across logout, and visibility rules that control when the inbox UI appears—well-tested but user-facing inbox behavior.

Overview
Aligns Android visual inbox behavior with iOS and hardens edge cases around enablement, caching, and what “visible” means.

Enablement: Missing or bad X-CIO-Inbox-Enabled no longer forces the inbox off; the last known flag is kept so 304/error polls don’t hide the bell.

Visibility & empty state: Templates + branding + enabled gate visibility; zero messages is still Visible (empty “caught up” UI). Overlay chrome stays gated on renderable messages. Drops unused fromCache and the no selected messages hidden reason.

Templates/branding session gate: Revalidation reopens on store sessionId change (logout/reset). The gate only closes when the fetch’s session is still current and there are servable assets—so mid-fetch reset, total fetch failure with no cache, and cross-user asset bleed are avoided. Branding JSON parse is memoized.

Overlay: One SDK-scoped VisualInboxController with shared uiStateFlow (no duplicate load/snapshot per composable). UI messages come from the same list as InboxVisibility, not a second getSelectedMessages() pass. messageOpened reports opened = true. Expiry filter uses expiry <= now.

Reviewed by Cursor Bugbot for commit 1b0df36. Bugbot is set up for automated code reviews on this repo. Configure here.

mahmoud-elmorabea and others added 2 commits July 27, 2026 12:26
- treat an absent/unparseable X-CIO-Inbox-Enabled header as no signal so a
  304 or failed poll no longer hides the inbox until the next clean 200
- reopen the once-per-session revalidation gate on a new store session id,
  so a logout cannot serve the previous user's templates/branding
- drop expired messages inclusively (expiry <= now) to match iOS
- remove the unused fromCache flag from InboxVisibility/InboxFetchOutcome;
  it was miscomputed when one asset was served from cache and the other refetched
- remove the unnecessary @OptIn(InternalCustomerIOApi) from both samples
- delete the stale MessagingInboxModuleTest placeholder

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Sample app builds 📱

Below you will find the list of the latest versions of the sample apps. It's recommended to always download the latest builds of the sample apps to accurately test the pull request.


@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.47059% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.63%. Comparing base (1f18b40) to head (1b0df36).

Files with missing lines Patch % Lines
...ustomer/messaginginbox/NotificationInboxOverlay.kt 0.00% 8 Missing ⚠️
...tomer/messaginginapp/inbox/data/InboxRepository.kt 87.50% 0 Missing and 3 partials ⚠️
...stomer/messaginginapp/gist/data/listeners/Queue.kt 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@                   Coverage Diff                    @@
##             feat/overlay-inbox     #799      +/-   ##
========================================================
- Coverage                 64.88%   64.63%   -0.25%     
- Complexity                 1033     1041       +8     
========================================================
  Files                       175      175              
  Lines                      6017     6034      +17     
  Branches                    940      949       +9     
========================================================
- Hits                       3904     3900       -4     
- Misses                     1791     1819      +28     
+ Partials                    322      315       -7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: inbox-review-fixes (1785146991)

@github-actions

Copy link
Copy Markdown
  • java_layout: inbox-review-fixes (1785146986)

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

📏 SDK Binary Size Comparison Report

Module Last Recorded Size Current Size Change in Size
core 33.61 KB 33.61 KB ✅ No Change
datapipelines 42.43 KB 42.43 KB ✅ No Change
messagingpush 31.67 KB 31.67 KB ✅ No Change
messaginginapp 147.98 KB 148.17 KB ⬆️ +0.19KB
messaginginbox 60.07 KB 60.36 KB ⬆️ +0.29KB
tracking-migration 22.89 KB 22.89 KB ✅ No Change

@github-actions

Copy link
Copy Markdown

Build available to test
Version: inbox-review-fixes-SNAPSHOT
Repository: https://central.sonatype.com/repository/maven-snapshots/

- share uiStateFlow via shareIn and make the controller a process singleton, so
  bell, panel and any embedded view observe one upstream and share the
  per-session dedupe guards instead of each running their own pipeline
- pass the collected state into the list instead of collecting a second time
- memoize the parsed branding against its raw payload; it is read several times
  per store change while the payload changes at most once a session
- build the snapshot from the message list the visibility decision already
  produced, dropping a duplicate selection plus Jist deep-copy
- correct the action docs: openUrl opens externally as the platform resolves it,
  matching how push and in-app already handle links; it was never http(s)-only

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
  • java_layout: inbox-review-fixes (1785157061)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: inbox-review-fixes (1785157082)

@github-actions

Copy link
Copy Markdown
  • java_layout: inbox-review-fixes (1785157469)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: inbox-review-fixes (1785157477)

…s lazy

A top-level lazy outlived CustomerIO teardown, so the next initialization got a
stale module reference and the instrumentation leak test failed. Graph-held
singletons are released on reset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
  • kotlin_compose: inbox-review-fixes (1785158046)

@github-actions

Copy link
Copy Markdown
  • java_layout: inbox-review-fixes (1785158028)

@mahmoud-elmorabea
mahmoud-elmorabea marked this pull request as ready for review July 27, 2026 14:18
@mahmoud-elmorabea
mahmoud-elmorabea requested a review from a team as a code owner July 27, 2026 14:18
…-fetch

A logout landing while a templates/branding fetch is in flight reopens the
session gate, but the completing fetch then closed it again, so the next
session served render assets it never revalidated. Close the gate only when
the session that started the cycle is still current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 70568c4. Configure here.

@github-actions

Copy link
Copy Markdown
  • java_layout: inbox-review-fixes (1785164611)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: inbox-review-fixes (1785164619)

The store is a StateFlow collected from a launched coroutine, so the first
delivery replays the current session id rather than signalling a change, and it
can land after a load has already closed the revalidation gate — costing a
redundant conditional GET. Reset only on a change away from a session id already
seen, matching the guards the other subscribeToAttribute call sites use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
  • java_layout: inbox-review-fixes (1785165597)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: inbox-review-fixes (1785165600)

private fun isExpired(message: InboxMessage, now: Date): Boolean {
val expiry = message.expiry ?: return false
return expiry.before(now)
return expiry <= now

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The <= check looks right. Could we keep a follow-up for time-driven expiry? Selection only reruns after another update, so an expired message can remain visible during a quiet SSE session.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, and I have deliberately left this one out of these PRs — it is a real gap but a behavioral change rather than a fix to what this PR touched, and it needs a design decision.

Confirming the diagnosis: expiry is applied on READ, in InboxSelection/VisualInboxSelector, and a read only happens when something re-triggers it — a queue poll, an SSE store update, or a templates/branding fetch completing. Nothing is time-driven, so during a quiet stretch an expired message stays on screen until the next unrelated update. iOS is identical (VisualInboxSelector.select(messages:now:) off the same read path), so any fix wants to land on both.

The open question is what drives re-evaluation. A wall-clock timer in the SDK is the obvious answer but means a periodic wake purely to hide a row, and it interacts with the polling interval the server already dictates. Scheduling a single one-shot at the earliest upcoming expiry — recomputed whenever the selection changes — gets the same result with one pending timer and no idle work, which is what I would propose.

Happy to open a follow-up with that shape unless you would rather it ride along here. One caveat either way: the expiry check is now inclusive (expiry <= now), so a timer firing exactly at the boundary does drop the message.

// Reuse the list the visibility decision was made from rather than re-selecting: a second
// read would repeat the selection and the Jist deep-copy, and could observe a store change
// in between and disagree with the state published alongside it.
val messages = (visibility as? InboxVisibility.Visible)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we remove getSelectedMessages() and its stale test setup? snapshot() now uses visibility.messages, so this method no longer has a production caller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 1b0df36 — confirmed, and it was worse than dead code.

getSelectedMessages() is gone from VisualInbox along with all eight references in VisualInboxControllerTest: five every { ... } returns stubs, one returnsMany, and two verify(exactly = 0) assertions that were only meaningful while the method existed. snapshot_givenHiddenButSelectableMessagesExist_... is renamed to snapshot_givenHidden_... since the real invariant it guards is that only InboxVisibility.Visible carries messages.

Checked before removing: @InternalCustomerIOApi, absent from messaginginapp.api, and unreferenced by the React Native and Flutter wrappers — so no baseline change and no wrapper break. apiDump validation is clean.

Worth passing on, because it nearly cost me: removing the method broke :messaginginbox test COMPILATION, and this repo's test task has ignoreFailures, so ./gradlew test printed BUILD SUCCESSFUL while that module silently produced no JUnit XML at all — 75 tests just vanished from the run rather than failing. I only caught it because I count tests out of the XML. Both modules now report: 592 tests, 0 failures.

… dead API

An empty selection no longer hides the inbox: a renderable inbox with nothing in
it is "You're all caught up", which the list already had an empty state for but
could never reach. Overlay chrome stays gated on having renderable messages, so
no bell appears over an empty panel.

The revalidation gate now closes only when there is something to serve. A failure
with an empty cache had no fallback, so closing it stranded the inbox hidden for
the rest of the process while later store updates re-read the same empty cache.

Removes getSelectedMessages(), whose last production caller went away when the
snapshot began reusing the visibility's message list, along with its test setup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
  • java_layout: inbox-review-fixes (1785178515)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: inbox-review-fixes (1785178515)

private fun isExpired(message: InboxMessage, now: Date): Boolean {
val expiry = message.expiry ?: return false
return expiry.before(now)
return expiry <= now

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The one-shot timer at the earliest expiry sounds like the right follow-up. Could you open and link that issue here so it is not lost? This does not need to block this PR.

emptyList()
}
//
// Reuse the list the visibility decision was made from rather than re-selecting: a second

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we update the remaining comments to match the new empty-inbox behavior? This file still says visibility requires a message and that dismissing the last row makes the inbox hidden. The old rule also remains in InboxRepository.computeVisibility, InboxFetchOutcome, and the repository test header.

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