Fix: a duplicate group invite demoted a member who had already joined - #2218
Merged
mpretty-cyro merged 1 commit intoSep 28, 2026
Conversation
mpretty-cyro
marked this pull request as ready for review
September 20, 2026 20:39
mpretty-cyro
force-pushed
the
fix/duplicate-invite-demotes-member
branch
from
September 28, 2026 03:34
bfae257 to
acb973d
Compare
Issue session-foundation#2215, reported with a reliable repro and against every Session Android version. handleInvitation built a fresh ClosedGroupInfo and wrote it over the existing record, so a second invitation for a group we were already in set invited back to true and discarded joinedAtSecs. It now checks the same thing handlePromotion already checks, and still rebuilds when we were kicked or the group was destroyed - a fresh invitation is how we get back in. A second invitation still has to be answered, though, and that is why this is not simply a guard. Our invite response is sent once, on approval, with its failure swallowed, and the admin marks us accepted only on receiving it - so an admin whose copy was lost has no repair except re-inviting, and the reported demotion is what that repair did on our side. The already-joined path now sends the response and drops the invitation from our swarm without touching membership, so both halves hold. Admin side of the same defect: inviteMembersInternal called setInvited() whatever state the member was in, and InviteContactsJob then wrote the send result over the top - so re-inviting someone who had accepted reset them twice, and fixing only the first place would have changed nothing. Both now leave anyone already in the group alone, using isAdminOrBeingPromoted so a promotion in flight counts as membership rather than as an invitation waiting on an answer. Tests cover the receiving side. The admin side has none: GroupMember's state setters are native, so no JVM unit test can reach them.
mpretty-cyro
force-pushed
the
fix/duplicate-invite-demotes-member
branch
from
September 28, 2026 03:59
acb973d to
bc6698e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue #2215, reported with a reliable repro and against every Session Android version.
Receiving side
GroupManagerV2Impl.handleInvitationhad no check for a group we are already in. It built a freshClosedGroupInfoand wrote it over the existing record, so a second invitation setinvitedback to true and resetjoinedAtSecsto 0 — a joined member was demoted to "invited" and their join time was lost.It now checks the same thing
handlePromotionalready checks — but conditionally, not literally. A literalgroup == nullcheck would break re-invitation after a kick:handleKickedkeeps the record withkicked = true, and an invitation that rebuilds it is the only way back in. The guard is therefore "already joined": present, and not invited, kicked or destroyed.Why a bare guard would have been a regression
A second invitation still has to be answered. Our invite response is sent once, on approval, inside a
runCatchingwhose failure is swallowed, and the admin marks usINVITE_ACCEPTEDonly on receiving it. So an admin whose copy was lost sees us as "Invite sent" indefinitely, and re-inviting is the only repair available to them — which means the reported demotion is what that repair did on our side.Simply ignoring the duplicate would have removed the repair and left the admin stuck, with Resend visibly doing nothing. The already-joined path therefore sends the invite response and drops the invitation from our swarm — the parts of approval the admin depends on — without touching membership or
joinedAtSecs. An admin member sends no response, because their membership is not established that way.Admin side
inviteMembersInternalcalledsetInvited()whatever state the member was in, andInviteContactsJobthen wrote the send result over the top. Re-inviting someone who had accepted reset them in both places, so fixing only the first would have changed nothing observable.Both now skip anyone already in the group:
INVITE_ACCEPTED, orisAdminOrBeingPromoted(status)— the predicateBaseGroupMembersViewModelalready uses forshowAsAdmin/canPromote, which isadmin || status in { PROMOTION_SENT, PROMOTION_ACCEPTED }. Naming the accepted statuses by hand would have missed a member whose promotion is still in flight, and the outcome of sending them another invitation says nothing about a membership they already have.Note this half is defensive rather than load-bearing: promotions never reach
InviteContactsJob(promoteMembersends and records its own results),canResendInviteis offered only forINVITE_SENT/INVITE_FAILED, and the invite picker excludes existing members. It closes the hole rather than a reachable regression.Tests
GroupManagerV2ImplTest, synthetic group config only:The control exists so the negative tests cannot pass by never reaching the code.
The admin side has no test:
GroupMember's state setters arenative, so no JVM unit test can reach them.Known limits, not addressed here
Rebased on
devd29053ab48. Unit suite: 315 pass, 0 fail.