Sitelet https://github.com/GetStream/stream-chat-android/pull/6704
Skip to content

Show the single answer poll subtitle when enforceUniqueVote is set - #6704

Merged
andremion merged 4 commits into
GetStream:developfrom
ryanhurststrava:fix/poll-subtitle-enforce-unique-vote
Sep 18, 2026
Merged

andremion merged 4 commits into
GetStream:developfrom
ryanhurststrava:fix/poll-subtitle-enforce-unique-vote

Conversation

@ryanhurststrava

@ryanhurststrava ryanhurststrava commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Goal

Poll.getSubtitle() shows "Select one or more" for polls that only allow a single answer, when those polls were created by the iOS SDK.

getSubtitle() decides the subtitle from maxVotesAllowed alone:

val maxVotes = maxVotesAllowed?.let { min(it, options.size) }
return when (maxVotes) {
    1 -> ...stream_ui_poll_description_single_answer
    null -> ...stream_ui_poll_description_unlimited_answers
    else -> ...stream_ui_poll_description_multiple_answers
}

Poll also carries enforceUniqueVote: Boolean, which is the authoritative "one answer only" signal, and it is ignored here.

Why this only shows up for iOS-created polls. When the Android SDK creates a single-answer poll it sets both fields (AttachmentsPickerPollUtils.kt: maxVotesAllowed = 1, enforceUniqueVote = true), so the 1 -> branch is hit and the subtitle is correct. The iOS SDK sends enforce_unique_vote: true and omits max_votes_allowed (the nil value is skipped by Swift's synthesized Encodable). The Android client therefore deserializes maxVotesAllowed == null, falls into the null -> branch, and renders the unlimited-answers string for a poll that permits exactly one vote.

When this became reachable. #6209 moved maxVotesAllowed from Int to Int? so that null could mean "unlimited votes". That is a deliberate and correct meaning for polls created without a vote limit — this PR does not undo it. The problem is that a payload omitting max_votes_allowed is currently treated as "unlimited" even when enforce_unique_vote: true is present, and enforceUniqueVote is exactly the field that disambiguates the two cases.

The broken branch ordering is unchanged as of the latest release, v7.11.0, and unchanged on current develop (this PR is based on 929fd8c51f4).

Cross-platform inconsistency. StreamChatSwiftUI's PollAttachmentView already checks enforceUniqueVote before maxVotesAllowed:

} else if poll.enforceUniqueVote == true {
    L10n.Message.Polls.Subtitle.selectOne
} else if let maxVotes = poll.maxVotesAllowed, maxVotes > 0 {

So the same poll reads "Select one" on iOS and "Select one or more" on Android.

Both Android render paths are affected, since they share this helper:

  • stream-chat-android-compose/.../ui/components/messages/PollMessageContent.kt
  • stream-chat-android-ui-components/.../messages/list/adapter/view/internal/PollView.kt

Tracked in AND-1539, filed by the Stream team after this PR was opened.

Implementation

The subtitle

One added branch in stream-chat-android-ui-common/.../utils/extensions/Poll.kt — check enforceUniqueVote before the maxVotesAllowed branches, so it wins regardless of whether maxVotesAllowed is present:

if (closed) {
    return context.getString(R.string.stream_ui_poll_description_closed)
}
if (enforceUniqueVote) {
    return context.getString(R.string.stream_ui_poll_description_single_answer)
}
val maxVotes = maxVotesAllowed?.let { min(it, options.size) }

The closed short-circuit and the min(maxVotesAllowed, options.size) clamp are unchanged. No public API signature changes (apiCheck passes with no dump needed).

The TalkBack role

PollOptionVotingRow.kt in the Compose kit derived its toggle role from maxVotesAllowed alone, so the same poll that now reads "Select one" was still announced to TalkBack as a checkbox. Raised by @andremion in review; folded in here rather than left for a follow-up, since shipping the subtitle fix alone would leave the visible text and the screen reader disagreeing about the same poll:

val toggleRole = if (poll.enforceUniqueVote || poll.maxVotesAllowed == 1) {
    Role.RadioButton
} else {
    Role.Checkbox
}

The row's KDoc is updated to match. Role is semantics-only, so this changes what TalkBack announces, not what is drawn. The cast/remove gating on the following lines is untouched and still reads maxVotesAllowed through canCastVote().

canCastVote() is deliberately left alone

public fun Poll.canCastVote(): Boolean {
    val limit = maxVotesAllowed ?: return true
    return ownVotes.size < limit
}

I asked whether this needed the same treatment. Answered in review: returning true for an enforceUniqueVote poll with a null maxVotesAllowed is correct, because the server treats a vote on a different option as a vote change.

The reverse case is a real bug, but a separate one. Polls created by the Android SDK carry both maxVotesAllowed = 1 and enforceUniqueVote = true, so canCastVote() returns false once the user holds a vote and a tap on another option is swallowed at PollOptionVotingRow.kt:111 and PollView.kt:308; the user has to deselect first, where iOS lets the switch through. That gates real vote casting across both UI kits and turns on server semantics, so it is out of scope here and left with the Stream team.

🎨 UI Changes

Text-only change on the poll subtitle line, for enforceUniqueVote polls with no maxVotesAllowed:

Before After
"Select one or more" "Select one"

No visual change beyond that line: the accompanying Compose change affects only the announced TalkBack role.

Existing Paparazzi snapshots are unaffected — PreviewPollData.poll1 sets maxVotesAllowed = 1 alongside enforceUniqueVote = true, so its subtitle is unchanged. :stream-chat-android-compose:verifyPaparazziDebug --tests "*PollMessageContentTest*" passes without re-recording.

Testing

To reproduce: create a poll from the iOS SDK with "Multiple answers" left off (and no per-person vote limit set), then view that poll in either Android UI kit. Before this change the subtitle reads "Select one or more"; after, "Select one".

PollExtensionsTest covers the branch table. Added:

  • enforceUniqueVote = true, maxVotesAllowed = null → single-answer string (the bug)
  • enforceUniqueVote = true, maxVotesAllowed = 1 → single-answer string (unchanged)
  • enforceUniqueVote = false, maxVotesAllowed = null → unlimited-answers string (regression guard: genuinely unlimited polls must not be caught by the new branch)

The pre-existing getSubtitle cases also had to pin enforceUniqueVote = false explicitly. They relied on randomPoll, whose enforceUniqueVote default is randomBoolean(), so once the new branch exists they would flake roughly half the time.

./gradlew :stream-chat-android-ui-common:testDebugUnitTest --tests "*PollExtensionsTest*"   # 10 tests, 0 failures
./gradlew :stream-chat-android-ui-common:spotlessCheck :stream-chat-android-ui-common:detekt :stream-chat-android-ui-common:apiCheck   # BUILD SUCCESSFUL
./gradlew :stream-chat-android-compose:verifyPaparazziDebug --tests "*PollMessageContentTest*"   # BUILD SUCCESSFUL
./gradlew :stream-chat-android-compose:spotlessCheck :stream-chat-android-compose:detekt   # BUILD SUCCESSFUL

The Compose change is not covered by a new test. It is a semantics-only role flip with no assertion on Role in the existing suites, and adding the harness for one felt disproportionate; happy to add one if you would rather have it pinned.

I ran module-scoped tasks only, not the full ./gradlew check or the instrumented/E2E suites.

☑️Contributor Checklist

General

  • I have signed the Stream CLA (required)
  • Assigned a person / code owner group (required)
  • PR targets the develop branch
  • PR is linked to the GitHub issue it resolves (AND-1539)

Code & documentation

  • New code is covered by unit tests
  • Comparison screenshots added for visual changes — text-only, described in the table above
  • Affected documentation updated (KDocs, docusaurus, tutorial) — the existing KDoc still describes the behaviour accurately

Summary by CodeRabbit

  • Bug Fixes
    • Poll subtitles now accurately describe single-answer voting when unique voting is enforced.
    • Poll subtitles continue to reflect multiple-answer and unlimited-answer settings correctly.

getSubtitle() branched on maxVotesAllowed alone, so a single-answer poll
that carries enforceUniqueVote = true without an explicit maxVotesAllowed
fell into the null branch and rendered "Select one or more".

Check enforceUniqueVote before the maxVotesAllowed branches, matching the
ordering the Swift SDK's PollAttachmentView already uses.
@gpunto gpunto added the pr:bug Bug fix label Sep 16, 2026
@andremion

Copy link
Copy Markdown
Contributor

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: f293374e-89bb-4f97-91da-a51db141f9a2

📥 Commits

Reviewing files that changed from the base of the PR and between 929fd8c and 77d4ba2.

📒 Files selected for processing (2)
  • stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Poll.kt
  • stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/PollExtensionsTest.kt

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


Walkthrough

Poll.getSubtitle now returns the single-answer description when enforceUniqueVote is enabled. Tests cover unique voting, vote limits, multiple answers, and unlimited answers.

Changes

Poll subtitle behavior

Layer / File(s) Summary
Subtitle selection and validation
stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Poll.kt, stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/PollExtensionsTest.kt
Poll.getSubtitle handles enforceUniqueVote before vote-limit checks. Tests cover unique voting, nullable and explicit limits, multiple answers, and unlimited answers.

Priority: ⬇️ Low

Estimated code review effort: 1 (Trivial) | ~5 minutes

Change: Bug fix

Suggested reviewers: gpunto

Merge Risk: ⚪ Minimal · up to 77d4b

Unique-vote polls now show the single-answer subtitle without changing closed-poll or non-unique behavior. The change is ready to merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: showing the single-answer poll subtitle when enforceUniqueVote is enabled.
Description check ✅ Passed The description is complete and relevant. It explains the goal, implementation, affected behavior, testing, scope decisions, and checklist status. The missing reviewer checklist and GIF do not prevent…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit checks each voting choice
Unique votes now speak with one clear voice
Limits and nulls line up just right
Tests hop through each case in sight
The poll subtitle shines bright

Comment @coderabbitai help to get the list of available commands.

@andremion andremion changed the title Respect enforceUniqueVote in Poll.getSubtitle Show the single answer poll subtitle when enforceUniqueVote is set Sep 16, 2026

@andremion andremion left a comment

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.

Looks good. The fix is right, and I confirmed on the iOS side that a single answer poll sends enforce_unique_vote: true and omits max_votes_allowed, so the null branch is exactly the problem you describe.

Two things that don't fit on a line:

  1. The same check exists once more, in PollOptionVotingRow.kt:109 in the Compose kit, outside this diff:
val toggleRole = if (poll.maxVotesAllowed == 1) Role.RadioButton else Role.Checkbox

For the same poll the subtitle will now read "Select one" while TalkBack still announces the options as checkboxes. Would you like to fix it here? It is the same one line shape. Also fine to leave it for a follow-up if you'd rather keep this PR small.

  1. Housekeeping done on our side: I filed AND-1539, linked it in the description, and adjusted the title. The pr-checklist checks themselves pass now, the job only stays red because it cannot post its own comment from a fork, so you can ignore it. The CLA is the one part we can't handle for you.

The toggle role was derived from maxVotesAllowed alone, so an
enforceUniqueVote poll with no vote limit read "Select one" while
TalkBack still announced its options as checkboxes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ryanhurststrava

Copy link
Copy Markdown
Contributor Author

Folded in the PollOptionVotingRow toggleRole fix, since leaving it would mean the subtitle and TalkBack disagree on the same poll. CLA is signed now. spotlessCheck and detekt pass on the compose module; no snapshot changes, as Role is semantics only and PreviewPollData.poll1 sets both fields so its role is unchanged either way.

@andremion
andremion marked this pull request as ready for review September 17, 2026 08:00
@andremion
andremion requested a review from a team as a code owner September 17, 2026 08:00

@andremion andremion left a comment

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.

Code looks good. One small thing: the description still only covers the Poll.kt change. Could you update it for the Compose one too?

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ryanhurststrava

Copy link
Copy Markdown
Contributor Author

Description updated. The implementation section is now split into the subtitle change and the TalkBack role change, the canCastVote part is rewritten to record your answer and why the reverse case is out of scope here, and the testing section notes that the role flip has no new test. KDoc fixed in f18bdbd. Thanks for the review.

@andremion
andremion added this pull request to the merge queue Sep 18, 2026
Merged via the queue into GetStream:develop with commit af1c43c Sep 18, 2026
13 of 14 checks passed
@stream-public-bot stream-public-bot added the released Included in a release label Sep 24, 2026
@stream-public-bot

Copy link
Copy Markdown
Contributor

🚀 Available in v7.12.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:bug Bug fix released Included in a release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants