Timeline a11y (#1059)

* Improve timeline accessibility

* Improve a11y description and actions in timeline

* Refactor timeline accessibility handling, add more actions

* Update app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java

Co-Authored-By: charlag <charlag@tutanota.com>

* Add a11y actions for links, hashtags and mentions, enable for detailed.

* A11y delegate: Add open reblogger action, cleanup

* a11y delegate: add reblogs/boosts, improve interrupts

* a11y delegate: add reblogs/boosts, improve interrupts

* a11y delegate: add to notifications fragment
This commit is contained in:
Ivan Kupalov 2019-03-04 19:24:27 +01:00 committed by Konrad Pozniak
commit 479d210e64
15 changed files with 646 additions and 78 deletions

View file

@ -2,12 +2,6 @@ package com.keylesspalace.tusky.adapter;
import android.content.Context;
import android.graphics.drawable.Drawable;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.recyclerview.widget.RecyclerView;
import android.text.InputFilter;
import android.text.Spanned;
import android.text.TextUtils;
import android.view.View;
@ -28,21 +22,26 @@ import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.DateUtils;
import com.keylesspalace.tusky.util.HtmlUtils;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.MediaPreviewImageView;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import com.mikepenz.iconics.utils.Utils;
import com.squareup.picasso.Picasso;
import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.lang.CharSequence;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.recyclerview.widget.RecyclerView;
import at.connyduck.sparkbutton.SparkButton;
import at.connyduck.sparkbutton.SparkEventListener;
import kotlin.collections.CollectionsKt;
public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
@ -70,6 +69,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private SimpleDateFormat shortSdf;
private SimpleDateFormat longSdf;
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
protected StatusBaseViewHolder(View itemView, boolean useAbsoluteTime) {
super(itemView);
displayName = itemView.findViewById(R.id.status_display_name);
@ -83,7 +84,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
moreButton = itemView.findViewById(R.id.status_more);
reblogged = false;
favourited = false;
mediaPreviews = new MediaPreviewImageView[] {
mediaPreviews = new MediaPreviewImageView[]{
itemView.findViewById(R.id.status_media_preview_0),
itemView.findViewById(R.id.status_media_preview_1),
itemView.findViewById(R.id.status_media_preview_2),
@ -121,10 +122,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
protected void setSpoilerAndContent(boolean expanded,
@NonNull Spanned content,
@NonNull Spanned content,
@Nullable String spoilerText,
@Nullable Status.Mention[] mentions,
@NonNull List<Emoji> emojis,
@Nullable Status.Mention[] mentions,
@NonNull List<Emoji> emojis,
final StatusActionListener listener) {
if (TextUtils.isEmpty(spoilerText)) {
contentWarningDescription.setVisibility(View.GONE);
@ -156,9 +157,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, this.content);
LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener);
} else {
LinkHelper.setClickableMentions(this.content, mentions, listener);
LinkHelper.setClickableMentions(this.content, mentions, listener);
}
if(TextUtils.isEmpty(this.content.getText())) {
if (TextUtils.isEmpty(this.content.getText())) {
this.content.setVisibility(View.GONE);
} else {
this.content.setVisibility(View.VISIBLE);
@ -178,37 +179,52 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected void setCreatedAt(@Nullable Date createdAt) {
if (useAbsoluteTime) {
String time;
if (createdAt != null) {
if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) {
time = longSdf.format(createdAt);
} else {
time = shortSdf.format(createdAt);
}
} else {
time = "??:??:??";
}
timestampInfo.setText(time);
timestampInfo.setText(getAbsoluteTime(createdAt));
} else {
// This is the visible timestampInfo.
String readout;
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
* as 17 meters instead of minutes. */
CharSequence readoutAloud;
if (createdAt != null) {
long then = createdAt.getTime();
long now = new Date().getTime();
readout = DateUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now);
readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now,
} else {
// unknown minutes~
readout = "?m";
}
timestampInfo.setText(readout);
}
}
private String getAbsoluteTime(@Nullable Date createdAt) {
String time;
if (createdAt != null) {
if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) {
time = longSdf.format(createdAt);
} else {
time = shortSdf.format(createdAt);
}
} else {
time = "??:??:??";
}
return time;
}
private CharSequence getCreatedAtDescription(@Nullable Date createdAt) {
if (useAbsoluteTime) {
return getAbsoluteTime(createdAt);
} else {
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
* as 17 meters instead of minutes. */
if (createdAt != null) {
long then = createdAt.getTime();
long now = new Date().getTime();
return android.text.format.DateUtils.getRelativeTimeSpanString(then, now,
android.text.format.DateUtils.SECOND_IN_MILLIS,
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE);
} else {
// unknown minutes~
readout = "?m";
readoutAloud = "? minutes";
return "? minutes";
}
timestampInfo.setText(readout);
timestampInfo.setContentDescription(readoutAloud);
}
}
@ -464,7 +480,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
listener.onReply(position);
}
});
if(reblogButton != null) {
if (reblogButton != null) {
reblogButton.setEventListener(new SparkEventListener() {
@Override
public void onEvent(ImageView button, boolean buttonState) {
@ -554,5 +570,125 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener);
setContentDescription(status);
// Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
// RecyclerView tries to set AccessibilityDelegateCompat to null
// but ViewCompat code replaces is with the default one. RecyclerView never
// fetches another one from its delegate because it checks that it's set so we remove it
// and let RecyclerView ask for a new delegate.
itemView.setAccessibilityDelegate(null);
}
private void setContentDescription(@Nullable StatusViewData.Concrete status) {
if (status == null) {
itemView.setContentDescription(
itemView.getContext().getString(R.string.load_more_placeholder_text));
} else {
setDescriptionForStatus(status);
}
}
private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status) {
Context context = itemView.getContext();
String description = context.getString(R.string.description_status,
status.getUserFullName(),
getContentWarningDescription(context, status),
(!status.isSensitive() || status.isExpanded() ? status.getContent() : ""),
getCreatedAtDescription(status.getCreatedAt()),
getReblogDescription(context, status),
status.getNickname(),
status.isReblogged() ? context.getString(R.string.description_status_reblogged) : "",
status.isFavourited() ? context.getString(R.string.description_status_favourited) : "",
getMediaDescription(context, status),
getVisibilityDescription(context, status.getVisibility()),
getFavsText(context, status.getFavouritesCount()),
getReblogsText(context, status.getReblogsCount())
);
itemView.setContentDescription(description);
}
private CharSequence getReblogDescription(Context context,
@NonNull StatusViewData.Concrete status) {
CharSequence reblogDescriontion;
String rebloggedUsername = status.getRebloggedByUsername();
if (rebloggedUsername != null) {
reblogDescriontion = context
.getString(R.string.status_boosted_format, rebloggedUsername);
} else {
reblogDescriontion = "";
}
return reblogDescriontion;
}
private CharSequence getMediaDescription(Context context,
@NonNull StatusViewData.Concrete status) {
if (status.getAttachments().isEmpty()) {
return "";
}
StringBuilder mediaDescriptions = CollectionsKt.fold(
status.getAttachments(),
new StringBuilder(),
(builder, a) -> {
if (a.getDescription() == null) {
String placeholder =
context.getString(R.string.description_status_media_no_description_placeholder);
return builder.append(placeholder);
} else {
builder.append("; ");
return builder.append(a.getDescription());
}
});
return context.getString(R.string.description_status_media, mediaDescriptions);
}
private CharSequence getContentWarningDescription(Context context,
@NonNull StatusViewData.Concrete status) {
if (!TextUtils.isEmpty(status.getSpoilerText())) {
return context.getString(R.string.description_status_cw, status.getSpoilerText());
} else {
return "";
}
}
private CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) {
int resource;
switch (visibility) {
case PUBLIC:
resource = R.string.description_visiblity_public;
break;
case UNLISTED:
resource = R.string.description_visiblity_unlisted;
break;
case PRIVATE:
resource = R.string.description_visiblity_private;
break;
case DIRECT:
resource = R.string.description_visiblity_direct;
break;
default:
return "";
}
return context.getString(resource);
}
protected CharSequence getFavsText(Context context, int count) {
if (count > 0) {
String countString = numberFormat.format(count);
return HtmlUtils.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString));
} else {
return "";
}
}
protected CharSequence getReblogsText(Context context, int count) {
if (count > 0) {
String countString = numberFormat.format(count);
return HtmlUtils.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString));
} else {
return "";
}
}
}

View file

@ -21,7 +21,6 @@ import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomURLSpan;
import com.keylesspalace.tusky.util.HtmlUtils;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import com.squareup.picasso.Picasso;
@ -44,8 +43,6 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
private TextView cardUrl;
private View infoDivider;
private NumberFormat numberFormat = NumberFormat.getNumberInstance();
StatusDetailedViewHolder(View view) {
super(view, false);
reblogs = view.findViewById(R.id.status_reblogs);
@ -74,36 +71,34 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
}
}
private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) {
private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) {
if(reblogCount > 0) {
String reblogCountString = numberFormat.format(reblogCount);
reblogs.setText(HtmlUtils.fromHtml(reblogs.getResources().getQuantityString(R.plurals.reblogs, reblogCount, reblogCountString)));
if (reblogCount > 0) {
reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount));
reblogs.setVisibility(View.VISIBLE);
} else {
reblogs.setVisibility(View.GONE);
}
if(favCount > 0) {
String favCountString = numberFormat.format(favCount);
favourites.setText(HtmlUtils.fromHtml(favourites.getResources().getQuantityString(R.plurals.favs, favCount, favCountString)));
if (favCount > 0) {
favourites.setText(getFavsText(favourites.getContext(), favCount));
favourites.setVisibility(View.VISIBLE);
} else {
favourites.setVisibility(View.GONE);
}
if(reblogs.getVisibility() == View.GONE && favourites.getVisibility() == View.GONE) {
if (reblogs.getVisibility() == View.GONE && favourites.getVisibility() == View.GONE) {
infoDivider.setVisibility(View.GONE);
} else {
infoDivider.setVisibility(View.VISIBLE);
}
reblogs.setOnClickListener( v -> {
reblogs.setOnClickListener(v -> {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onShowReblogs(position);
}
});
favourites.setOnClickListener( v -> {
favourites.setOnClickListener(v -> {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onShowFavs(position);
@ -140,7 +135,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
View.OnLongClickListener longClickListener = view -> {
TextView textView = (TextView)view;
TextView textView = (TextView) view;
ClipboardManager clipboard = (ClipboardManager) view.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText("toot", textView.getText());
clipboard.setPrimaryClip(clip);
@ -153,7 +148,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
content.setOnLongClickListener(longClickListener);
contentWarningDescription.setOnLongClickListener(longClickListener);
if(status.getAttachments().size() == 0 && status.getCard() != null && !TextUtils.isEmpty(status.getCard().getUrl())) {
if (status.getAttachments().size() == 0 && status.getCard() != null && !TextUtils.isEmpty(status.getCard().getUrl())) {
final Card card = status.getCard();
cardView.setVisibility(View.VISIBLE);
cardTitle.setText(card.getTitle());
@ -161,10 +156,10 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
cardUrl.setText(card.getUrl());
if(card.getWidth() > 0 && card.getHeight() > 0 && !TextUtils.isEmpty(card.getImage())) {
if (card.getWidth() > 0 && card.getHeight() > 0 && !TextUtils.isEmpty(card.getImage())) {
cardImage.setVisibility(View.VISIBLE);
if(card.getWidth() > card.getHeight()) {
if (card.getWidth() > card.getHeight()) {
cardView.setOrientation(LinearLayout.VERTICAL);
cardImage.getLayoutParams().height = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_image_vertical_height);

View file

@ -16,8 +16,6 @@
package com.keylesspalace.tusky.adapter;
import android.content.Context;
import androidx.annotation.Nullable;
import android.text.InputFilter;
import android.view.View;
import android.widget.ImageView;
@ -30,6 +28,7 @@ import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import com.squareup.picasso.Picasso;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import at.connyduck.sparkbutton.helpers.Utils;
@ -92,9 +91,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
}
rebloggedBar.setOnClickListener(v -> listener.onOpenReblog(getAdapterPosition()));
}
}
private void setRebloggedByDisplayName(final String name) {
@ -133,4 +130,4 @@ public class StatusViewHolder extends StatusBaseViewHolder {
content.setFilters(NO_INPUT_FILTER);
}
}
}
}