Skip to content

feat(groups): private groups - #1762

Merged
bmc08gt merged 25 commits into
code/cashfrom
feat/private-groups-on-cash
Oct 13, 2026
Merged

bmc08gt merged 25 commits into
code/cashfrom
feat/private-groups-on-cash

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Private groups on Android: create one, request to join, approve or deny as host, and read and send end-to-end encrypted messages as a member. Uses the group cipher from #1755.

This re-lands #1756 on code/cash. #1756 merged into feat/chat-cipher-group-messages, its stacked base, after #1755 had already merged, so none of it reached code/cash. The commits are the same, cherry-picked onto code/cash, plus one test fix: ChatShareGatewayTest from #1757 now passes the groupCrypto that outgoing encryption takes.

One commit per slice, then follow-ups:

  • Create (nodes 11203:59, 11203:65): a Private option in group creation. If the server denies a private create, the sheet now says "private groups".
  • Request to join (11204:59, 11204:69, 11204:81, 11210:59): a non-member opening a private group sees its lobby and a Request to Join button. A pending request shows its age on the button after the first minute.
  • Host review (11205:68, 11206:60, 11214:67, 11362:237, 11362:207): Join Requests screen with Approve and Deny.
  • In-chat anchor (11354:70, 11354:81, 11354:83): pending requests show under the host's top bar and open Join Requests.
  • Members, encryption, leave (11206:98, 11210:68, 11210:70, 11210:80, 11210:71): members grid and list, the private-group E2EE sheet (reactions listed as not encrypted), and private-group leave copy.
  • Member decrypt: after admission a member opens the host's key envelope, saves its own copy with SetKeyEnvelope, and reads and sends scheme 2 messages. The Encrypted marker in a private group opens the private-group sheet, not the DM one.
  • Requests: a ⋯ menu at the leading edge of the Chats header, matching group info's menu, replaces the pinned Join Requests row. Its one item, Requests, opens a screen with "Waiting on you" (hosted groups with pending requests, each opening that group's Join Requests) and "Sent" (requests made from this device, each opening the group's profile). A green dot on the menu and the item marks requests waiting on you. Same design as iOS in code-ios-app#1048.
  • Encryption fixes from a comparison with iOS:
    • A private group's push shows the decrypted text.
    • A failed SetKeyEnvelope no longer drops the member's key; the save is best effort, as on iOS.
    • A key wrapped by anyone other than the host or self shows the update-app hint.
    • A sealed photo in a chat not yet stored waits for the key instead of failing.
    • The resend hint names the message's author, not "Unnamed".
    • New tests cover sending, sealing photos and pushes in private groups.

The members list reads the roster through GetRoster, which the server currently returns as UNAVAILABLE ("roster reads are disabled"), so it stays hidden until that flag is on. Until then the members grid is built on the device: host, then self, then anyone who has posted, with no count. Once GetRoster returns members, the roster replaces it.

New Chat gets a "Create a Private Group" row (node 11203:59) that opens the
public group form without Minimum Balance, with the private-group footnote
under the name (node 11203:65).

Create is two calls: StartChat(private_group), then SetKeyEnvelope with the
creator's own envelope. If the envelope fails, it is retried three times; after
that the next Create goes straight back to the envelope rather than starting a
second chat. A finished create opens the group's profile, not the chat.

ChatCipher gains the KeyEnvelope X25519_XCHACHA20POLY1305 wrap, following
chat/v1/model.proto steps 1-4. It shares the DM key's X25519 + HKDF steps
under the flipcash-group-key-wrap-v1 label. There are no shared vectors for it
yet, so GroupKeyWrapTest checks round trips and rejections rather than bytes.
GroupKeyEnvelopes owns the key: generate and store on create, wrap for a
recipient on approve. ALREADY_SET counts as success, since a retry after a lost
response lands there.
…tnote

A private group's name and image stay editable after create, so the sentence was wrong.
A private group's profile now shows Request to Join to a non-member, then
Request Sent with "Requested <age> · ...once <host> approves" and Cancel
Request (EnterLobby / LeaveLobby). LOBBY_FULL and TOO_MANY_LOBBIES get their
own sheets; ALREADY_MEMBER refreshes the feed.

Requested chat ids are stored per account and dropped once GetChat reports
in_lobby false, checked when the profile opens and when the chat list loads.
A private group a non-member opens by link or QR swaps to its profile.

chat_metadata gains is_private (schema 45). The upsert ORs it in, since a
group never stops being private and a partial row must not reset it.
The pending button reads "Request Sent" for the first minute, then
"Requested 10m ago", "2h ago", "3d ago". The line above it keeps only
"You'll be able to open the chat once <host> approves.", so the age is no
longer said twice.
HostLobbies (shared/chat) holds each hosted group's lobby: it pages
GetLobbyMembers, applies LobbyUpdate from the event stream, and on
Approve wraps the group key for the requester before AdmitLobbyMember.
Deny and a requester who already left both just drop the row.

The host sees the lobby in three places, all read from that store:
- Join Requests (node 11206:60), with its empty state (node 11362:237):
  text and Share Group, no icon.
- A card on the group profile with a grey count (node 11205:68), hidden
  at 0.
- A row pinned above Archived in the chat list under the All filter
  (node 11214:67). With several hosted groups waiting it opens the one
  with the newest request; there is no switcher frame.

Edit Group has no Balance Requirements section on Android, so nothing
needed hiding there.
The host of a private group sees a glass card under the chat top bar
while anyone is waiting: up to three avatars, "<name> wants to join" with
the request's age for one requester, or "N people want to join" with the
first names, and a Review button that opens Join Requests. It reads the
host lobby store from the previous slice, so it hides as soon as the last
request is approved, denied or withdrawn.

The card sits in the scaffold's top slot under ChatTopBar, so the
transcript's top inset grows by its height. It blurs the transcript with
the same haze recipe as ComposerReplyStrip and draws flat when there is
no HazeState.

Figma nodes 11354:70, 11354:81, 11354:83.
A member's private-group profile shows a Members grid with a count that
opens a full list (host pinned with a Host tag, "Joined …" lines).
GroupRosters pages GetRoster and applies RosterUpdate by version, fed
from the same path as RosterStateHolder.

The Encryption menu item gets a filled lock for a private group and
opens a private-group E2EE sheet (node 11210:80). The Reactions row is
left out until its encryption status is confirmed. Leaving a private
group warns that rejoining needs the host's approval.

Dialog copy added on this branch (leave, lobby full, too many lobbies,
request/cancel/approve/deny failures) drops its trailing period.

GetRoster currently returns UNAVAILABLE ("roster reads are disabled"),
so the grid stays hidden until the server enables it.
…heet

Matches node 11210:80: reactions in a private group are not encrypted, as in a DM.
The denial sheet said "public groups" whatever form was open.
An admitted member's key envelope is wrapped by the creator, and
GroupKeyEnvelopes only ever unwrapped against the viewer's own key, so
a member could not open the group key, read the transcript, or send.

- GroupKeyEnvelopes follows GetKeyEnvelope.wrapped_by: self (or unset)
  opens with the own key; the creator opens with the creator's key, then
  self-wraps and stores it with SetKeyEnvelope (ALREADY_SET refetches);
  any other wrapper is rejected. NO_ENVELOPE regenerates the key only
  for the creator.
- GroupContentCrypto seals and opens scheme 2 messages and blobs under
  the group key, with the sender's user id in the AAD.
- E2eePolicy encrypts every private group. Send, photo sealing, the
  incoming opener and the photo loader branch on the chat's is_private
  before the single-peer DM path.
- ChatPreviewsReadyTest gets the lobby and roster constructor args it
  missed when it arrived from code/cash.
…ed marker

The marker in a private group opened the DM copy ("only you and the
person you're chatting with"). Pick the sheet from the chat subject.
…s header

The pinned Join Requests row on the Chats list is replaced by a button on
the leading side of the Chats header. It shows while a request is pending
in either direction and carries a green dot only when someone is waiting
on the viewer in a group they host; sent requests alone show the button
without a dot.

The button opens a Requests screen with two sections. "Waiting on you"
lists hosted private groups with people waiting, newest request first,
each opening that group's Join Requests. "Sent" lists groups the viewer
asked to join, with how long ago, each opening the group's profile where
Cancel Request lives. Sent knows only about requests made on this device,
since the server can't list them.

NotificationDot is the 11dp ring-cut dot from node 11440:189, kept in
ui/components so the settings gear can reuse it. RequestAge moves to
shared/chat so the tipping module can word the request age.
The leading header item is now a three-dot menu with one item, Requests,
as on iOS. It shows while a request is pending either way; the dot sits on
the three-dot icon and marks only requests the viewer has to decide. The
item's second line counts the people waiting.
…s item

As on iOS, the menu shows with no pending requests, since Requests has an
empty state. The Requests item drops its people-waiting line and carries
the green dot instead, and the header dot moves in to sit just above and
right of the top dot.
…reads open

GetRoster is off and SampleChatters is denied for private groups, so the
Members grid was hidden. Until the roster loads, it shows the members this
device knows: the host, then the viewer, then everyone who has posted,
newest first, named from the profiles on the device. Anyone without a
profile is left out. The grid has no count while it shows this list, since
there is no full list to open. A loaded roster replaces it.
The server can't read a private group message, so its push body is a
stand-in. Open it on the device as DMs already do; unencrypted group
messages open to nothing and keep the server's body.
A key wrapped by someone other than the viewer or the host never opens,
so asking the sender to resend can't help. Show the update-app hint,
as iOS does.
ChatPhotoLoader picked the DM path whenever the chat's row was missing,
so a private group photo opened against an arbitrary member and failed
for good. An unstored chat now reports KeyPending, which retries.
GroupContentCrypto had no direct test, and the send and push tests only
stubbed it out. Cover text and photo sends under the group key, a send
with no key, group push bodies, and the chat, sender and blob-id binding.
@bmc08gt bmc08gt self-assigned this Oct 13, 2026
@github-actions github-actions Bot added type: feature New functionality area: ui Compose UI, theme, components, resources area: network gRPC, connectivity, API, exchange rates and removed type: feature New functionality labels Oct 13, 2026
@github-actions github-actions Bot added the area: notifications Push notifications, in-app messaging label Oct 13, 2026
@bmc08gt
bmc08gt merged commit ff44fde into code/cash Oct 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging area: ui Compose UI, theme, components, resources

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant