Skip to content

feat(chat): group chat RPCs and roster updates - #1467

Draft
bmc08gt wants to merge 4 commits into
code/cashfrom
feat/group-chat-rpcs
Draft

bmc08gt wants to merge 4 commits into
code/cashfrom
feat/group-chat-rpcs

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Adds the client side of the flipcash2 group chat contract, and pins
flipcash2-client-protocol to 0.7.0.

Blocked on code-payments/flipcash2-client-protocol#13.
0.7.0 is not on Maven Central yet, so dependency resolution fails here until that PR merges and
publishes — expect com.flipcash:flipcash2-client-protocol:0.7.0 FAILED. Draft until then. The work
itself was written and built against the contract checkout through protoLocalRoot, which CI never
sees.

What this adds

Four RPCs through the existing Api → Service → Repository → Controller layering:
getGroupChatFeed, startChat, joinChat, leaveChat, each with a sealed error type mapping the
result enum case by case. startChat surfaces the server's moderation category as
StartChatError.TitleModerated(flaggedCategory), reusing ModerationResult.FlaggedCategory rather
than introducing a second enum for the same values.

Roster updates — a domain RosterUpdate (MemberJoined/MemberLeft), ChatUpdate.rosterUpdates,
and the mapping for both. EventStreamDelegate applies them per chat gated on
RosterSummary.version, dropping anything not strictly greater than the version it holds. A member
joining or leaving updates the member rows; the recipient joining inserts the chat from the metadata
snapshot the update carries, and the recipient leaving removes it. That last case needed a per-chat
delete on ChatMetadataDao and a per-member delete on ChatMemberDao, which did not exist.

ChatUpdate.new_messages moving to reserved needs nothing here — the last reference went in #1441.

Two things worth a reviewer's attention

Roster versions are tracked in memory, not in Room. EventStreamDelegate holds a
MutableMap<ChatId, Long>. chat_metadata has no roster-version column, so a ChatMetadata rebuilt
from Room already defaults to RosterSummary(0, 0); tracking in memory matches that and avoids a
schema migration. The cost is that a process restart re-applies the first roster update it sees per
chat, which is safe for a convergent overlay — real versions are always ≥ 1 — and the proto's own
contract is that an unreconcilable version heals via a roster refetch. A migration is the alternative
if durable versioning turns out to matter.

An unrecognized RosterUpdate.kind is dropped, not defaulted. toRosterUpdateOrNull returns
null for an oneof arm this client does not know about, and the call site filters it with mapNotNull,
so it cannot reach the delegate and cannot advance the version tracker. Mapping it to a default would
record a version as applied while applying nothing, which would defeat the refetch reconciliation.

Tested

:services:flipcash:test (24) and :apps:flipcash:shared:chat:test (14, including five new roster
tests) pass, and :apps:flipcash:app:assembleDebug builds against the local contract checkout.

flipcash2's chat_service.proto defines GetGroupChatFeed, StartChat,
JoinChat, and LeaveChat, plus a RosterUpdate stream message for join/leave
notifications, but the Kotlin service layer only had the DM chat path.

Add the Api/Service/Repository/Controller methods for all four RPCs,
following the existing getChat/getDmChatFeed pattern: request validation
via protovalidate, Result mapping through foldWithSuppression, and owner
resolution from UserManager.accountCluster. StartChat returns
Result<ChatModel.Metadata> rather than a dedicated result wrapper, since
that already matches getChat's single-object shape, and folds the
TITLE_MODERATED case into a new StartChatError.TitleModerated carrying the
flagged category.

Add four sealed error types (GetGroupChatFeedError, StartChatError,
JoinChatError, LeaveChatError), a RosterUpdate domain model
(MemberJoined/MemberLeft) with mapping in ProtobufToLocal, and the
ChatRules/BlobId-to-proto extensions StartChat's request needs in
LocalToProtobuf. ChatUpdate gains a rosterUpdates list, populated from the
proto's new roster_updates field; the event-stream consumer for it lands
in a follow-up commit.

ChatControllerTest's FakeChatRepository now implements the four new
ChatRepository methods, with a test region per RPC mirroring the existing
getChat/getDmChatFeed coverage (owner-resolution failure, argument
forwarding, success, and failure passthrough).
ChatUpdate.rosterUpdates (added in the prior commit) reaches
EventStreamDelegate.applyUpdate but nothing consumed it, so a member
joining or leaving a group chat never touched Room: the roster changed
server-side, but a client already displaying that chat wouldn't see
membership change, and a client not yet in it wouldn't see the chat
appear.

Track each chat's last-applied RosterSummary.version in an in-memory
map on the delegate rather than a Room column: the field's doc comment
lays out why — chat_metadata has no version today, and treating this as
the same kind of durable-but-not-authoritative signal as the event-sequence
cursor would need a migration for a value that's cheap to reconstruct (a
missed update self-heals via refetch, per the proto's own doc comment on
RosterSummary). Applying an update whose version isn't greater than the
tracked one is a no-op, so replays and reordered delivery converge.

MemberJoined upserts the joining member, and additionally upserts chat
metadata and its full member list when the update carries metadata — that
only happens when the recipient is the one who joined, so this is how a
newly-joined chat gets inserted into the local chat list without a
separate feed refetch. MemberLeft deletes the chat's metadata and member
rows when the leaving user is the caller (self-removal), otherwise just
removes that one member row.

Add roster coverage to ChatCoordinatorEventsTest: the version drop rule,
recipient-join insert, non-recipient-join member-only upsert, recipient-
leave chat removal, and non-recipient-leave member removal. Promote
memberDataSource from a local mock in setUp() to a test-class field so
these tests can coVerify against it, matching metadataDataSource and
messageDataSource.

ChatMemberDao/ChatMemberDataSource and ChatMetadataDao/ChatMetadataDataSource
gain deleteMember/removeMember and deleteById/delete respectively, needed
by the recipient-leave and non-recipient-leave paths above; neither existed
before this since nothing previously deleted a single member row or a whole
chat.
…mberLeft

An unset RosterUpdate.kind is how a future oneof arm looks to an
already-shipped client. Mapping it to a fabricated MemberLeft(userId =
emptyList()) let EventStreamDelegate record that update's version as
applied while doing nothing, permanently blocking the refetch the proto
relies on to reconcile a version a client can't understand.

toRosterUpdateOrNull now returns null for that case, and toChatUpdate
drops it via mapNotNull before it reaches the delegate, so the version
stays unadvanced until a reconcilable update or a roster refetch heals
it.
Group chat RPCs and roster updates need the 0.7.0 contract. The version is
not on Maven Central yet, so resolution fails until it publishes.
@bmc08gt bmc08gt self-assigned this Sep 15, 2026
@github-actions github-actions Bot added type: feature New functionality area: network gRPC, connectivity, API, exchange rates area: build-system Gradle, convention plugins, build-logic labels Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: build-system Gradle, convention plugins, build-logic area: network gRPC, connectivity, API, exchange rates type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant