diff --git a/src/main/assets/changelog-alpha.txt b/src/main/assets/changelog-alpha.txt
index 994d87fd3..36af6dbee 100644
--- a/src/main/assets/changelog-alpha.txt
+++ b/src/main/assets/changelog-alpha.txt
@@ -1,4 +1,5 @@
/Alpha 370 (2026-09-13)
+Accessibility announcements for collapsing/expanding comments, and voting (thanks to codeofdusk)
Fixes for inline image preview size in landscape mode
Improved memory usage for inline image previews
diff --git a/src/main/assets/changelog.txt b/src/main/assets/changelog.txt
index 4c7fa2fcc..23c2ddb98 100644
--- a/src/main/assets/changelog.txt
+++ b/src/main/assets/changelog.txt
@@ -1,4 +1,5 @@
119/1.27
+Accessibility announcements for collapsing/expanding comments, and voting (thanks to codeofdusk)
Fixes for inline image preview size in landscape mode
Improved memory usage for inline image previews
diff --git a/src/main/java/org/quantumbadger/redreader/common/General.kt b/src/main/java/org/quantumbadger/redreader/common/General.kt
index ee7448c97..98ce739d1 100644
--- a/src/main/java/org/quantumbadger/redreader/common/General.kt
+++ b/src/main/java/org/quantumbadger/redreader/common/General.kt
@@ -36,9 +36,12 @@ import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.view.WindowManager.BadTokenException
+import android.view.accessibility.AccessibilityEvent
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
+import androidx.core.view.AccessibilityDelegateCompat
+import androidx.core.view.ViewCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.quantumbadger.redreader.BuildConfig
import org.quantumbadger.redreader.R
@@ -190,6 +193,48 @@ object General {
}
}
+ // announceForAccessibility was deprecated in Baklava, but the suggested
+ // replacements don't cover brief action confirmations:
+ // https://issuetracker.google.com/issues/425271162
+ @Suppress("DEPRECATION")
+ @JvmStatic
+ fun announceForAccessibility(view: View, textRes: Int) {
+ runOnUiThread {
+ view.announceForAccessibility(view.context.getString(textRes))
+ }
+ }
+
+ // TalkBack reads a view's raw text aloud when it changes under accessibility
+ // focus, ignoring any content description. Install this on views whose content
+ // description already covers their text; description changes still go through.
+ @JvmStatic
+ fun suppressAccessibilityTextChangeEvents(view: View) {
+ ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
+ override fun sendAccessibilityEventUnchecked(
+ host: View,
+ event: AccessibilityEvent
+ ) {
+ if (event.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED) {
+ return
+ }
+
+ if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {
+ val changeTypes = event.contentChangeTypes
+
+ if ((changeTypes and AccessibilityEvent.CONTENT_CHANGE_TYPE_TEXT) != 0
+ && (changeTypes
+ and AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION)
+ == 0
+ ) {
+ return
+ }
+ }
+
+ super.sendAccessibilityEventUnchecked(host, event)
+ }
+ })
+ }
+
@JvmStatic
fun isTablet(context: Context) = when (PrefsUtility.appearance_twopane()) {
AppearanceTwopane.AUTO -> context.resources.configuration.screenLayout and
diff --git a/src/main/java/org/quantumbadger/redreader/fragments/CommentListingFragment.java b/src/main/java/org/quantumbadger/redreader/fragments/CommentListingFragment.java
index 6bb47a615..f54b1d741 100644
--- a/src/main/java/org/quantumbadger/redreader/fragments/CommentListingFragment.java
+++ b/src/main/java/org/quantumbadger/redreader/fragments/CommentListingFragment.java
@@ -321,11 +321,12 @@ public void handleCommentVisibilityToggle(final RedditCommentView view) {
if(item.isComment()) {
final RedditRenderableComment comment = item.asComment();
+ final boolean nowCollapsed = !comment.isCollapsed(changeDataManager);
changeDataManager.markHidden(
TimestampUTC.now(),
comment.getIdAndType(),
- !comment.isCollapsed(changeDataManager));
+ nowCollapsed);
mCommentListingManager.updateHiddenStatus();
@@ -336,6 +337,12 @@ public void handleCommentVisibilityToggle(final RedditCommentView view) {
if(position == layoutManager.findFirstVisibleItemPosition()) {
layoutManager.scrollToPositionWithOffset(position, 0);
}
+
+ General.announceForAccessibility(
+ view,
+ nowCollapsed
+ ? R.string.accessibility_announcement_comment_collapsed
+ : R.string.accessibility_announcement_comment_expanded);
}
}
diff --git a/src/main/java/org/quantumbadger/redreader/reddit/api/RedditAPICommentAction.java b/src/main/java/org/quantumbadger/redreader/reddit/api/RedditAPICommentAction.java
index f00696fa0..14aa5e8da 100644
--- a/src/main/java/org/quantumbadger/redreader/reddit/api/RedditAPICommentAction.java
+++ b/src/main/java/org/quantumbadger/redreader/reddit/api/RedditAPICommentAction.java
@@ -636,6 +636,28 @@ protected void onSuccess() {
if(action == RedditAPI.ACTION_DELETE) {
General.quickToast(context, R.string.delete_success);
}
+
+ if(!vote) {
+ return;
+ }
+
+ final int announcementRes;
+
+ switch(action) {
+ case RedditAPI.ACTION_UPVOTE:
+ announcementRes = R.string.accessibility_announcement_upvoted;
+ break;
+ case RedditAPI.ACTION_DOWNVOTE:
+ announcementRes = R.string.accessibility_announcement_downvoted;
+ break;
+ default:
+ announcementRes = R.string.accessibility_announcement_removed_vote;
+ break;
+ }
+
+ General.announceForAccessibility(
+ activity.getWindow().getDecorView(),
+ announcementRes);
}
private void revertOnFailure() {
diff --git a/src/main/java/org/quantumbadger/redreader/reddit/api/RedditPostActions.kt b/src/main/java/org/quantumbadger/redreader/reddit/api/RedditPostActions.kt
index a3de4a6ad..c9332fc24 100644
--- a/src/main/java/org/quantumbadger/redreader/reddit/api/RedditPostActions.kt
+++ b/src/main/java/org/quantumbadger/redreader/reddit/api/RedditPostActions.kt
@@ -1138,6 +1138,17 @@ object RedditPostActions {
else -> throw java.lang.RuntimeException("Unknown post action")
}
+
+ if (vote) {
+ General.announceForAccessibility(
+ activity.window.decorView,
+ when (action) {
+ RedditAPI.ACTION_UPVOTE -> R.string.accessibility_announcement_upvoted
+ RedditAPI.ACTION_DOWNVOTE -> R.string.accessibility_announcement_downvoted
+ else -> R.string.accessibility_announcement_removed_vote
+ }
+ )
+ }
}
private fun revertOnFailure() {
diff --git a/src/main/java/org/quantumbadger/redreader/views/RedditCommentView.java b/src/main/java/org/quantumbadger/redreader/views/RedditCommentView.java
index f309b124f..ab64fa239 100644
--- a/src/main/java/org/quantumbadger/redreader/views/RedditCommentView.java
+++ b/src/main/java/org/quantumbadger/redreader/views/RedditCommentView.java
@@ -342,6 +342,7 @@ public RedditCommentView(
mIndentView = rootView.findViewById(R.id.view_reddit_comment_indentview);
mHeader = rootView.findViewById(R.id.view_reddit_comment_header);
+ General.suppressAccessibilityTextChangeEvents(mHeader);
mBodyHolder = rootView.findViewById(R.id.view_reddit_comment_bodyholder);
mIndentedContent = rootView.findViewById(R.id.view_reddit_comment_indented_content);
diff --git a/src/main/java/org/quantumbadger/redreader/views/RedditPostHeaderView.java b/src/main/java/org/quantumbadger/redreader/views/RedditPostHeaderView.java
index 8b1452c95..037858472 100644
--- a/src/main/java/org/quantumbadger/redreader/views/RedditPostHeaderView.java
+++ b/src/main/java/org/quantumbadger/redreader/views/RedditPostHeaderView.java
@@ -89,6 +89,7 @@ public RedditPostHeaderView(
subtitle.setTextSize(13.0f * subtitleFontScale);
subtitle.setText(post.buildSubtitle(activity, true));
subtitle.setContentDescription(post.buildAccessibilitySubtitle(activity, true));
+ General.suppressAccessibilityTextChangeEvents(subtitle);
subtitle.setTextColor(Color.rgb(200, 200, 200));
greyHeader.addView(subtitle);
diff --git a/src/main/java/org/quantumbadger/redreader/views/RedditPostView.java b/src/main/java/org/quantumbadger/redreader/views/RedditPostView.java
index 26d021b6b..e4b4bb188 100644
--- a/src/main/java/org/quantumbadger/redreader/views/RedditPostView.java
+++ b/src/main/java/org/quantumbadger/redreader/views/RedditPostView.java
@@ -228,6 +228,8 @@ public void handleMessage(@NonNull final Message msg) {
title = Objects.requireNonNull(rootView.findViewById(R.id.reddit_post_title));
subtitle = Objects.requireNonNull(rootView.findViewById(R.id.reddit_post_subtitle));
+ General.suppressAccessibilityTextChangeEvents(subtitle);
+
mCommentsButtonPref =
PrefsUtility.appearance_post_show_comments_button();
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index 2ceccd41e..d969cd651 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -1953,4 +1953,22 @@
pref_about_github
Source code
https://github.com/QuantumBadger/RedReader
+
+
+
+
+ Upvoted
+
+ Downvoted
+
+ Removed vote
+
+ Collapsed comment
+
+ Expanded
diff --git a/src/test/java/org/quantumbadger/redreader/test/general/AccessibilityAnnouncementLengthTest.java b/src/test/java/org/quantumbadger/redreader/test/general/AccessibilityAnnouncementLengthTest.java
new file mode 100644
index 000000000..68ca3ba83
--- /dev/null
+++ b/src/test/java/org/quantumbadger/redreader/test/general/AccessibilityAnnouncementLengthTest.java
@@ -0,0 +1,126 @@
+/*******************************************************************************
+ * This file is part of RedReader.
+ *
+ * RedReader is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * RedReader is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with RedReader. If not, see .
+ ******************************************************************************/
+
+package org.quantumbadger.redreader.test.general;
+
+import org.junit.Test;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import java.io.File;
+import java.util.ArrayList;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class AccessibilityAnnouncementLengthTest {
+
+ // TalkBack only reports an announcement uninterruptibly when it is at most
+ // VERBOSE_UTTERANCE_THRESHOLD_CHARACTERS (30) characters long. Longer
+ // announcements are merely queued, and the queue is flushed when
+ // accessibility focus moves, so an over-length announcement made just
+ // before a focus change (such as an action confirmation) is discarded
+ // silently. This test keeps every locale's announcement strings under
+ // the limit.
+ private static final int TALKBACK_UNINTERRUPTIBLE_MAX_LENGTH = 30;
+
+ private static final String ANNOUNCEMENT_PREFIX = "accessibility_announcement_";
+
+ @Test
+ public void announcementStringsFitTalkBackThreshold() throws Exception {
+
+ final File resDir = new File("src/main/res");
+
+ assertTrue(
+ "Resource directory not found: " + resDir.getAbsolutePath(),
+ resDir.isDirectory());
+
+ final File[] valueDirs = resDir.listFiles(
+ (dir, name) -> name.startsWith("values"));
+
+ assertNotNull(valueDirs);
+
+ final DocumentBuilder builder
+ = DocumentBuilderFactory.newInstance().newDocumentBuilder();
+
+ final ArrayList failures = new ArrayList<>();
+ int checked = 0;
+
+ for(final File valueDir : valueDirs) {
+
+ final File stringsFile = new File(valueDir, "strings.xml");
+
+ if(!stringsFile.isFile()) {
+ continue;
+ }
+
+ final Document document = builder.parse(stringsFile);
+ final NodeList strings = document.getElementsByTagName("string");
+
+ for(int i = 0; i < strings.getLength(); i++) {
+
+ final Element element = (Element)strings.item(i);
+ final String name = element.getAttribute("name");
+
+ if(!name.startsWith(ANNOUNCEMENT_PREFIX)) {
+ continue;
+ }
+
+ final String text = unescapeAndroidString(element.getTextContent());
+ checked++;
+
+ if(text.length() > TALKBACK_UNINTERRUPTIBLE_MAX_LENGTH) {
+ failures.add(valueDir.getName() + "/" + name + " is "
+ + text.length() + " characters: \"" + text + "\"");
+ }
+ }
+ }
+
+ assertTrue(
+ "No announcement strings found; has the naming prefix changed?",
+ checked > 0);
+
+ assertTrue(
+ "Announcement strings longer than TalkBack's uninterruptible "
+ + "limit of " + TALKBACK_UNINTERRUPTIBLE_MAX_LENGTH
+ + " characters (these would be silently discarded when "
+ + "accessibility focus moves):\n"
+ + String.join("\n", failures),
+ failures.isEmpty());
+ }
+
+ private static String unescapeAndroidString(final String s) {
+
+ String res = s.trim();
+
+ if(res.length() >= 2
+ && res.startsWith("\"")
+ && res.endsWith("\"")) {
+ res = res.substring(1, res.length() - 1);
+ }
+
+ return res
+ .replace("\\'", "'")
+ .replace("\\\"", "\"")
+ .replace("\\n", "\n")
+ .replace("\\t", "\t");
+ }
+}