Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/assets/changelog-alpha.txt
Original file line number Diff line number Diff line change
@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/main/assets/changelog.txt
Original file line number Diff line number Diff line change
@@ -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

Expand Down
45 changes: 45 additions & 0 deletions src/main/java/org/quantumbadger/redreader/common/General.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
18 changes: 18 additions & 0 deletions src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1953,4 +1953,22 @@
<string name="pref_about_github_key" translatable="false">pref_about_github</string>
<string name="pref_about_github_title">Source code</string>
<string name="pref_about_github_url" translatable="false">https://github.com/QuantumBadger/RedReader</string>

<!-- 2026-08-22 -->
<!--
Screen reader announcements. These must stay at most 30 characters long,
including in translation: TalkBack silently discards longer announcements
when accessibility focus moves just afterwards. A unit test
(AccessibilityAnnouncementLengthTest) enforces this for every locale.
-->
<!-- Translators: spoken by the screen reader after a vote. Max 30 characters. -->
<string name="accessibility_announcement_upvoted">Upvoted</string>
<!-- Translators: spoken by the screen reader after a vote. Max 30 characters. -->
<string name="accessibility_announcement_downvoted">Downvoted</string>
<!-- Translators: spoken by the screen reader after removing a vote. Max 30 characters. -->
<string name="accessibility_announcement_removed_vote">Removed vote</string>
<!-- Translators: spoken by the screen reader after collapsing a comment. Max 30 characters. -->
<string name="accessibility_announcement_comment_collapsed">Collapsed comment</string>
<!-- Translators: spoken by the screen reader after expanding a comment. Max 30 characters. -->
<string name="accessibility_announcement_comment_expanded">Expanded</string>
</resources>
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
******************************************************************************/

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<String> 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");
}
}
Loading