Skip to content

feat(chat): sync group rosters and search members locally - #1622

Closed
bmc08gt wants to merge 7 commits into
code/cashfrom
feat/roster-search
Closed

bmc08gt wants to merge 7 commits into
code/cashfrom
feat/roster-search

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

The @mention picker needs to search a group's members as someone types, but chat_members only holds the members the app happened to see, and ChatController.getRoster reads one page. This PR stores each group's full roster locally with a prefix index and puts search behind an interface. There's no UI yet; the picker is the next PR.

No new RPC. It uses the paged Chat.GetRoster and the versioned MemberJoined/MemberLeft stream updates. iOS implements the same rules in 403e551f, so the two platforms search and rank the same way.

Search

  • New table chat_member_search_tokens (chat_id_hex, user_id_hex, token), indexed on (chat_id_hex, token). Each word of the display name and the username is a token.
  • Tokens and queries are normalized the same way: NFKD, drop combining marks, lowercase. So "eri" finds "Érica".
  • A lookup is a range scan, token >= q AND token < q + U+10FFFF. A DAO test asserts the query plan uses the index. The upper bound is U+10FFFF rather than U+FFFF so tokens continuing with an emoji still match.
  • One leading @ is stripped per query word, so @@eri does not match eri.
  • RosterSearchSource.search(chatId, query, limit) is the seam. Only LocalRosterSearchSource ships; a server-side search can implement the same interface later.
  • Ranking: recent speakers in the chat's newest 50 held messages, then an exact username match, then display name. Names compare by code point and ties break on the lowercase hex user ID, matching iOS. The current user is never returned.

Keeping the roster current

  • chat_roster_sync stores, per chat, the last roster version fully applied (the watermark), plus whether a full read has completed and whether a reconcile is pending.
  • On group open, RosterSync reads from the top of the roster, which is newest-joined first, and stops at the first member whose Member.version is at or below the watermark. That's usually one page. Comparing the stored count with member_count after that:
    • Equal: the watermark moves to the page's roster_summary.version.
    • More held: someone left, so the chat is flagged and a full reload is queued.
    • Fewer held: the join arrives on the stream.
  • Full reloads (the leave case, and the first read of a roster over 100 members) run in RosterReconcileWorker, which follows the GiftCardFundingWorker pattern. It's unique work per chat, needs a network connection, uses exponential backoff, and is capped at 20 pages.
  • A leave keeps a marker row (is_member = 0, stamped with the leave's version) instead of deleting it. A page that trails the stream can't bring the member back, and a rejoin at a higher version does. Every read of chat_members skips markers, and a complete reload clears them.
  • The stopping rule assumes Member.version only changes on join. The proto says it will also move on role changes, so RosterSync.catchUp has a REVISIT comment there.

Schema

The database goes from version 40 to 41 as an AutoMigration: two new tables, plus version and is_member columns on chat_members with defaults. RosterSearchMigrationTest covers the migration with MigrationTestHelper. The exported schemas reach it as unit-test assets through the androidComponents variant API, because the android { sourceSets } DSL throws a cast error under AGP 9.4. The test also opens a v40 file with the app's own builder and checks that the rows survive, since that builder falls back to destructive migration.

The @mention picker needs to find a group's members by any word of their
name or their handle, locally. Adds chat_member_search_tokens (chat_id,
user_id, token), indexed on (chat_id, token), so a query is an index range
scan: token >= q AND token < q || U+10FFFF. The bound is U+10FFFF rather
than U+FFFF so a token continuing with an emoji still falls inside it.

Tokens are the display name split on whitespace plus the handle without
its @, folded by NFKD, then root-locale lowercase, then dropping Mn marks,
so "eri" finds "Érica". Every writer of members or profiles keeps them in
step: roster upserts, removals, UserProfileDataSource.store and blocked
users.

chat_roster_sync records how far each group's roster has been read. It is
its own table because chat_metadata is rewritten whole from each feed
payload. Both tables arrive in an additive 40 -> 41 auto-migration.
GetRoster returns at most 100 members a page, and until now only the first
page was ever read. RosterSync walks the pages to has_more = false, capped
at 20 pages, when a group opens and the device holds fewer members than
member_count, has never read it, or the stream skipped a roster version.
It runs off the screen's path and never on launch. A read drops absent
members only when every page reported one roster version and the stream
has not moved past it.

RosterSearchSource is the seam the picker will call; LocalRosterSearchSource
is the only implementation. It excludes the current user and ranks recent
speakers among the chat's last 50 held messages first, then an exact handle
match, then by folded display name. An empty query returns the recent
speakers. refresh() re-reads the first page, since profile edits do not
move the roster version.
…rsion

Store Member.version on chat_members and merge it greater-wins, so a
roster page that trails the stream cannot wind a member back. Replace
needs_resync with a watermark, fully_synced and reconcile_pending, and
make a full read drop only members who joined at or before the version
it read, as the proto's reconcile rule requires.

Schema v41 is unreleased, so it changes in place; the 40 to 41
auto-migration still only adds columns and tables.
… WorkManager

A missed roster update no longer forces a full reload. On open, or on a
version gap in a group already read in full, read GetRoster from the
top and stop at the first member at or below the watermark, usually
one page. Then compare the held count to member_count: equal moves the
watermark to the page's version, more held means someone left, so the
chat is marked for reconcile and a full read is queued.

The full read runs as unique WorkManager work per chat (KEEP, network
required, exponential backoff) through the existing HiltWorkerFactory,
and so does the first read of a roster over one page.
…ke iOS

A MemberLeft deleted the member row, so a GetRoster page that trailed
the stream re-added them at their older join version. model.proto says
a trailing page cannot resurrect a member the stream removed. Keep a
marker row instead (is_member = 0, version = the leave's roster
version, search words dropped). The member upsert leaves a marker in
place unless the incoming version is higher, which only a rejoin has.
Every read of chat_members skips markers, a feed refresh keeps them,
and a complete roster read clears those at or below its version.
advancePointer now copies the row rather than rebuilding it, which
would have reset the version and re-added the member.

Search ranking compares names by code point rather than UTF-16 unit,
and breaks the last tie on the lowercase hex user id instead of signed
decimal bytes, so both platforms return members in the same order.
MigrationTestHelper on Android reads exported schemas from test assets,
and Room 2.8.5's Android artifact has no schema-directory constructor.
The android { sourceSets } DSL fails with a cast error under AGP 9.4, so
the schemas are added through androidComponents' hostTests variant API.

The test seeds v40 chats, members and messages, migrates with
validateDroppedTables, and checks that row counts hold, existing members
take version=0 and is_member=1, and the new tables and token index exist
and are empty. It also opens the v40 file with the app's own builder:
that builder falls back to destructive migration, so the test asserts
the rows survive rather than only that the open succeeds.
@bmc08gt bmc08gt self-assigned this Sep 30, 2026
@github-actions github-actions Bot added the type: feature New functionality label Sep 30, 2026
@bmc08gt

bmc08gt commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Parked: Chat.GetRoster is staying private for now, so roster sync would fail on every group chat. The mention picker landed without it in #1647, using Chat.GetMentionSuggestions. This can come back as a second RosterSearchSource implementation once the roster is available.

@bmc08gt

bmc08gt commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #1647, which suggests mentions from the server's GetMentionSuggestions pool instead of a locally synced roster.

@bmc08gt bmc08gt closed this Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant