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
parent 244a478eb5
commit a9524508e6
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);
@ -178,6 +179,22 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected void setCreatedAt(@Nullable Date createdAt) {
if (useAbsoluteTime) {
timestampInfo.setText(getAbsoluteTime(createdAt));
} else {
String readout;
if (createdAt != null) {
long then = createdAt.getTime();
long now = new Date().getTime();
readout = DateUtils.getRelativeTimeSpanString(timestampInfo.getContext(), 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) {
@ -188,27 +205,26 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} else {
time = "??:??:??";
}
timestampInfo.setText(time);
return time;
}
private CharSequence getCreatedAtDescription(@Nullable Date createdAt) {
if (useAbsoluteTime) {
return 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,
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);
}
}
@ -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);
@ -77,15 +74,13 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
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)));
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)));
favourites.setText(getFavsText(favourites.getContext(), favCount));
favourites.setVisibility(View.VISIBLE);
} else {
favourites.setVisibility(View.GONE);

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) {

View File

@ -122,7 +122,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable {
}
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View) {
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
viewMedia(attachmentIndex, it.toStatus(), view)
}

View File

@ -47,6 +47,7 @@ import com.keylesspalace.tusky.network.TimelineCases;
import com.keylesspalace.tusky.util.CollectionUtil;
import com.keylesspalace.tusky.util.Either;
import com.keylesspalace.tusky.util.HttpHeaderLink;
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.PairedList;
import com.keylesspalace.tusky.util.ThemeUtils;
@ -186,6 +187,16 @@ public class NotificationsFragment extends SFragment implements
recyclerView.setHasFixedSize(true);
layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager);
recyclerView.setAccessibilityDelegateCompat(
new ListStatusAccessibilityDelegate(recyclerView, this, (pos) -> {
NotificationViewData notification = notifications.getPairedItem(pos);
// We support replies only for now
if (notification instanceof NotificationViewData.Concrete) {
return ((NotificationViewData.Concrete) notification).getStatusViewData();
} else {
return null;
}
}));
recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL));

View File

@ -189,7 +189,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable {
}
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View) {
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = searchAdapter.getStatusAtPosition(position) ?: return
viewMedia(attachmentIndex, status, view)
}

View File

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.fragment;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.preference.PreferenceManager;
@ -27,6 +28,8 @@ import android.widget.ProgressBar;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.tabs.TabLayout;
import com.keylesspalace.tusky.AccountListActivity;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.TimelineAdapter;
import com.keylesspalace.tusky.appstore.BlockEvent;
@ -50,6 +53,7 @@ import com.keylesspalace.tusky.repository.TimelineRepository;
import com.keylesspalace.tusky.repository.TimelineRequestMode;
import com.keylesspalace.tusky.util.CollectionUtil;
import com.keylesspalace.tusky.util.Either;
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.PairedList;
import com.keylesspalace.tusky.util.StringUtils;
@ -347,6 +351,8 @@ public class TimelineFragment extends SFragment implements
}
private void setupRecyclerView() {
recyclerView.setAccessibilityDelegateCompat(
new ListStatusAccessibilityDelegate(recyclerView, this, statuses::getPairedItem));
Context context = recyclerView.getContext();
recyclerView.setHasFixedSize(true);
layoutManager = new LinearLayoutManager(context);
@ -630,6 +636,21 @@ public class TimelineFragment extends SFragment implements
updateAdapter();
}
@Override
public void onShowReblogs(int position) {
String statusId = statuses.get(position).asRight().getId();
Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.REBLOGGED, statusId);
((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent);
}
@Override
public void onShowFavs(int position) {
String statusId = statuses.get(position).asRight().getId();
Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.FAVOURITED, statusId);
((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent);
}
@Override
public void onLoadMore(int position) {
//check bounds before accessing list,
@ -684,7 +705,7 @@ public class TimelineFragment extends SFragment implements
}
@Override
public void onViewMedia(int position, int attachmentIndex, @NonNull View view) {
public void onViewMedia(int position, int attachmentIndex, @Nullable View view) {
Status status = statuses.get(position).asRightOrNull();
if (status == null) return;
super.viewMedia(attachmentIndex, status, view);

View File

@ -15,37 +15,27 @@
package com.keylesspalace.tusky.fragment;
import androidx.arch.core.util.Function;
import androidx.lifecycle.Lifecycle;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.preference.PreferenceManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.snackbar.Snackbar;
import androidx.core.util.Pair;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.SimpleItemAnimator;
import android.text.TextUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.AccountListActivity;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewThreadActivity;
import com.keylesspalace.tusky.adapter.ThreadAdapter;
import com.keylesspalace.tusky.appstore.EventHub;
import com.keylesspalace.tusky.appstore.BlockEvent;
import com.keylesspalace.tusky.appstore.EventHub;
import com.keylesspalace.tusky.appstore.FavoriteEvent;
import com.keylesspalace.tusky.appstore.ReblogEvent;
import com.keylesspalace.tusky.appstore.StatusComposedEvent;
@ -57,6 +47,7 @@ import com.keylesspalace.tusky.entity.StatusContext;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.network.TimelineCases;
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
import com.keylesspalace.tusky.util.PairedList;
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.ThemeUtils;
@ -70,6 +61,16 @@ import java.util.Locale;
import javax.inject.Inject;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.arch.core.util.Function;
import androidx.core.util.Pair;
import androidx.lifecycle.Lifecycle;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.SimpleItemAnimator;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import io.reactivex.android.schedulers.AndroidSchedulers;
import retrofit2.Call;
import retrofit2.Callback;
@ -147,6 +148,8 @@ public final class ViewThreadFragment extends SFragment implements
recyclerView.setHasFixedSize(true);
LinearLayoutManager layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager);
recyclerView.setAccessibilityDelegateCompat(
new ListStatusAccessibilityDelegate(recyclerView, this, statuses::getPairedItem));
DividerItemDecoration divider = new DividerItemDecoration(
context, layoutManager.getOrientation());
recyclerView.addItemDecoration(divider);

View File

@ -18,14 +18,20 @@ package com.keylesspalace.tusky.interfaces;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public interface StatusActionListener extends LinkListener {
void onReply(int position);
void onReblog(final boolean reblog, final int position);
void onFavourite(final boolean favourite, final int position);
void onMore(@NonNull View view, final int position);
void onViewMedia(int position, int attachmentIndex, @NonNull View view);
void onViewMedia(int position, int attachmentIndex, @Nullable View view);
void onViewThread(int position);
/**
* Open reblog author for the status.
* @param position At which position in the list status is located
*/
void onOpenReblog(int position);
void onExpandedChange(boolean expanded, int position);
void onContentHiddenChange(boolean isShowing, int position);

View File

@ -0,0 +1,297 @@
package com.keylesspalace.tusky.util
import android.content.Context
import android.os.Bundle
import android.text.Spannable
import android.text.style.URLSpan
import android.view.View
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityManager
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.viewdata.StatusViewData
// Not using lambdas because there's boxing of int then
interface StatusProvider {
fun getStatus(pos: Int): StatusViewData?
}
class ListStatusAccessibilityDelegate(
private val recyclerView: RecyclerView,
private val statusActionListener: StatusActionListener,
private val statusProvider: StatusProvider
) : RecyclerViewAccessibilityDelegate(recyclerView) {
private val a11yManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE)
as AccessibilityManager
override fun getItemDelegate(): AccessibilityDelegateCompat = itemDelegate
private val context: Context get() = recyclerView.context
private val itemDelegate = object : RecyclerViewAccessibilityDelegate.ItemDelegate(this) {
override fun onInitializeAccessibilityNodeInfo(host: View,
info: AccessibilityNodeInfoCompat) {
super.onInitializeAccessibilityNodeInfo(host, info)
val pos = recyclerView.getChildAdapterPosition(host)
val status = statusProvider.getStatus(pos) ?: return
if (status is StatusViewData.Concrete) {
if (!status.spoilerText.isNullOrEmpty()) {
info.addAction(if (status.isExpanded) collapseCwAction else expandCwAction)
}
info.addAction(replyAction)
if (status.rebloggingEnabled) {
info.addAction(if (status.isReblogged) unreblogAction else reblogAction)
}
info.addAction(if (status.isFavourited) unfavouriteAction else favouriteAction)
val mediaActions = intArrayOf(
R.id.action_open_media_1,
R.id.action_open_media_2,
R.id.action_open_media_3,
R.id.action_open_media_4)
for (i in 0 until status.attachments.size) {
info.addAction(AccessibilityActionCompat(
mediaActions[i],
context.getString(R.string.action_open_media_n, i + 1)))
}
info.addAction(openProfileAction)
if (getLinks(status).any()) info.addAction(linksAction)
val mentions = status.mentions
if (mentions != null && mentions.isNotEmpty()) info.addAction(mentionsAction)
if (getHashtags(status).any()) info.addAction(hashtagsAction)
if (!status.rebloggedByUsername.isNullOrEmpty()) {
info.addAction(openRebloggerAction)
}
if (status.reblogsCount > 0) info.addAction(openRebloggedByAction)
if (status.favouritesCount > 0) info.addAction(openFavsAction)
}
}
override fun performAccessibilityAction(host: View, action: Int,
args: Bundle?): Boolean {
val pos = recyclerView.getChildAdapterPosition(host)
when (action) {
R.id.action_reply -> {
interrupt()
statusActionListener.onReply(pos)
}
R.id.action_favourite -> statusActionListener.onFavourite(true, pos)
R.id.action_unfavourite -> statusActionListener.onFavourite(false, pos)
R.id.action_reblog -> statusActionListener.onReblog(true, pos)
R.id.action_unreblog -> statusActionListener.onReblog(false, pos)
R.id.action_open_profile -> {
interrupt()
statusActionListener.onViewAccount(
(statusProvider.getStatus(pos) as StatusViewData.Concrete).senderId)
}
R.id.action_open_media_1 -> {
interrupt()
statusActionListener.onViewMedia(pos, 0, null)
}
R.id.action_open_media_2 -> {
interrupt()
statusActionListener.onViewMedia(pos, 1, null)
}
R.id.action_open_media_3 -> {
interrupt()
statusActionListener.onViewMedia(pos, 2, null)
}
R.id.action_open_media_4 -> {
interrupt()
statusActionListener.onViewMedia(pos, 3, null)
}
R.id.action_expand_cw -> {
statusActionListener.onExpandedChange(true, pos)
// Stop and restart narrator before it reads old description.
// Would be nice if we could *just* read the content here but doesn't seem
// to be possible.
forceFocus(host)
}
R.id.action_collapse_cw -> {
statusActionListener.onExpandedChange(false, pos)
interrupt()
}
R.id.action_links -> showLinksDialog(host)
R.id.action_mentions -> showMentionsDialog(host)
R.id.action_hashtags -> showHashtagsDialog(host)
R.id.action_open_reblogger -> {
interrupt()
statusActionListener.onOpenReblog(pos)
}
R.id.action_open_reblogged_by -> {
interrupt()
statusActionListener.onShowReblogs(pos)
}
R.id.action_open_faved_by -> {
interrupt()
statusActionListener.onShowFavs(pos)
}
else -> return super.performAccessibilityAction(host, action, args)
}
return true
}
private fun showLinksDialog(host: View) {
val status = getStatus(host) as? StatusViewData.Concrete ?: return
val links = getLinks(status).toList()
val textLinks = links.map { item -> item.link }
AlertDialog.Builder(host.context)
.setTitle(R.string.title_links_dialog)
.setAdapter(ArrayAdapter<String>(
host.context,
android.R.layout.simple_list_item_1,
textLinks)
) { _, which -> LinkHelper.openLink(links[which].link, host.context) }
.show()
.let { forceFocus(it.listView) }
}
private fun showMentionsDialog(host: View) {
val status = getStatus(host) as? StatusViewData.Concrete ?: return
val mentions = status.mentions ?: return
val stringMentions = mentions.map { it.username }
AlertDialog.Builder(host.context)
.setTitle(R.string.title_mentions_dialog)
.setAdapter(ArrayAdapter<CharSequence>(host.context,
android.R.layout.simple_list_item_1, stringMentions)
) { _, which ->
statusActionListener.onViewAccount(mentions[which].id)
}
.show()
.let { forceFocus(it.listView) }
}
private fun showHashtagsDialog(host: View) {
val status = getStatus(host) as? StatusViewData.Concrete ?: return
val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList()
AlertDialog.Builder(host.context)
.setTitle(R.string.title_hashtags_dialog)
.setAdapter(ArrayAdapter<CharSequence>(host.context,
android.R.layout.simple_list_item_1, tags)
) { _, which ->
statusActionListener.onViewTag(tags[which].toString())
}
.show()
.let { forceFocus(it.listView) }
}
private fun getStatus(childView: View): StatusViewData {
return statusProvider.getStatus(recyclerView.getChildAdapterPosition(childView))!!
}
}
private fun getLinks(status: StatusViewData.Concrete): Sequence<LinkSpanInfo> {
val content = status.content
return if (content is Spannable) {
content.getSpans(0, content.length, URLSpan::class.java)
.asSequence()
.map { span ->
val text = content.subSequence(
content.getSpanStart(span),
content.getSpanEnd(span))
if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url)
}
.filterNotNull()
} else {
emptySequence()
}
}
private fun getHashtags(status: StatusViewData.Concrete): Sequence<CharSequence> {
val content = status.content
return content.getSpans(0, content.length, Object::class.java)
.asSequence()
.map { span ->
content.subSequence(content.getSpanStart(span), content.getSpanEnd(span))
}
.filter(this::isHashtag)
}
private fun forceFocus(host: View) {
interrupt()
host.post {
host.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)
}
}
private fun interrupt() {
a11yManager.interrupt()
}
private fun isHashtag(text: CharSequence) = text.startsWith("#")
private val collapseCwAction = AccessibilityActionCompat(
R.id.action_collapse_cw,
context.getString(R.string.status_content_warning_show_less))
private val expandCwAction = AccessibilityActionCompat(
R.id.action_expand_cw,
context.getString(R.string.status_content_warning_show_more))
private val replyAction = AccessibilityActionCompat(
R.id.action_reply,
context.getString(R.string.action_reply))
private val unreblogAction = AccessibilityActionCompat(
R.id.action_unreblog,
context.getString(R.string.action_unreblog))
private val reblogAction = AccessibilityActionCompat(
R.id.action_reblog,
context.getString(R.string.action_reblog))
private val unfavouriteAction = AccessibilityActionCompat(
R.id.action_unfavourite,
context.getString(R.string.action_unfavourite))
private val favouriteAction = AccessibilityActionCompat(
R.id.action_favourite,
context.getString(R.string.action_favourite))
private val openProfileAction = AccessibilityActionCompat(
R.id.action_open_profile,
context.getString(R.string.action_view_profile))
private val linksAction = AccessibilityActionCompat(
R.id.action_links,
context.getString(R.string.action_links))
private val mentionsAction = AccessibilityActionCompat(
R.id.action_mentions,
context.getString(R.string.action_mentions))
private val hashtagsAction = AccessibilityActionCompat(
R.id.action_hashtags,
context.getString(R.string.action_hashtags))
private val openRebloggerAction = AccessibilityActionCompat(
R.id.action_open_reblogger,
context.getString(R.string.action_open_reblogger))
private val openRebloggedByAction = AccessibilityActionCompat(
R.id.action_open_reblogged_by,
context.getString(R.string.action_open_reblogged_by))
private val openFavsAction = AccessibilityActionCompat(
R.id.action_open_faved_by,
context.getString(R.string.action_open_faved_by))
private data class LinkSpanInfo(val text: String, val link: String)
}

View File

@ -54,8 +54,8 @@ public abstract class StatusViewData {
private final String id;
private final Spanned content;
private final boolean reblogged;
private final boolean favourited;
final boolean reblogged;
final boolean favourited;
@Nullable
private final String spoilerText;
private final Status.Visibility visibility;
@ -65,7 +65,7 @@ public abstract class StatusViewData {
@Nullable
private final String rebloggedAvatar;
private final boolean isSensitive;
private final boolean isExpanded;
final boolean isExpanded;
private final boolean isShowingContent;
private final String userFullName;
private final String nickname;
@ -86,7 +86,7 @@ public abstract class StatusViewData {
@Nullable
private final Card card;
private final boolean isCollapsible; /** Whether the status meets the requirement to be collapse */
private final boolean isCollapsed; /** Whether the status is shown partially or fully */
final boolean isCollapsed; /** Whether the status is shown partially or fully */
public Concrete(String id, Spanned content, boolean reblogged, boolean favourited,
@Nullable String spoilerText, Status.Visibility visibility, List<Attachment> attachments,

View File

@ -8,6 +8,7 @@
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:focusable="true"
android:paddingLeft="14dp"
android:paddingRight="14dp">
@ -19,6 +20,7 @@
android:drawableStart="?attr/status_reblog_small_drawable"
android:drawablePadding="6dp"
android:gravity="center_vertical"
android:importantForAccessibility="no"
android:paddingStart="38dp"
android:textColor="?android:textColorTertiary"
android:textSize="?attr/status_text_medium"
@ -35,6 +37,7 @@
android:layout_height="48dp"
android:layout_marginTop="14dp"
android:contentDescription="@string/action_view_profile"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_reblogged"
@ -45,6 +48,7 @@
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/status_avatar"
app:layout_constraintEnd_toEndOf="@id/status_avatar"
@ -58,6 +62,7 @@
android:layout_marginStart="14dp"
android:layout_marginTop="10dp"
android:ellipsize="end"
android:importantForAccessibility="no"
android:maxLines="1"
android:paddingEnd="@dimen/status_display_name_padding_end"
android:textColor="?android:textColorPrimary"
@ -75,6 +80,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:importantForAccessibility="no"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
@ -88,6 +94,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:importantForAccessibility="no"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
@ -98,6 +105,7 @@
android:id="@+id/status_content_warning_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:importantForAccessibility="no"
android:lineSpacingMultiplier="1.1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium"
@ -115,6 +123,7 @@
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:background="?attr/content_warning_button"
android:importantForAccessibility="no"
android:minWidth="150dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
@ -136,6 +145,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:focusable="true"
android:importantForAccessibility="no"
android:lineSpacingMultiplier="1.1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium"
@ -151,6 +161,7 @@
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:background="?attr/content_warning_button"
android:importantForAccessibility="no"
android:minWidth="150dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
@ -171,6 +182,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/status_media_preview_margin_top"
android:importantForAccessibility="noHideDescendants"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/button_toggle_content"
@ -287,6 +299,7 @@
android:layout_height="0dp"
android:background="?attr/sensitive_media_warning_background_color"
android:gravity="center"
android:importantForAccessibility="no"
android:lineSpacingMultiplier="1.2"
android:orientation="vertical"
android:padding="8dp"
@ -307,6 +320,7 @@
android:background="?attr/selectableItemBackground"
android:drawablePadding="4dp"
android:gravity="center_vertical"
android:importantForAccessibility="no"
android:textSize="?attr/status_text_medium"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
@ -322,6 +336,7 @@
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:contentDescription="@string/action_reply"
android:importantForAccessibility="no"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/status_reblog"
@ -336,6 +351,7 @@
android:layout_height="30dp"
android:clipToPadding="false"
android:contentDescription="@string/action_reblog"
android:importantForAccessibility="no"
android:padding="4dp"
app:layout_constraintEnd_toStartOf="@id/status_favourite"
app:layout_constraintStart_toEndOf="@id/status_reply"
@ -352,6 +368,7 @@
android:layout_height="30dp"
android:clipToPadding="false"
android:contentDescription="@string/action_favourite"
android:importantForAccessibility="no"
android:padding="4dp"
app:layout_constraintEnd_toStartOf="@id/status_more"
app:layout_constraintStart_toEndOf="@id/status_reblog"
@ -369,6 +386,7 @@
android:layout_height="30dp"
android:layout_marginEnd="8dp"
android:contentDescription="@string/action_more"
android:importantForAccessibility="no"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@id/status_reply"
app:layout_constraintEnd_toEndOf="parent"

View File

@ -18,6 +18,7 @@
android:layout_marginTop="14dp"
android:layout_marginEnd="14dp"
android:contentDescription="@string/action_view_profile"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
@ -31,6 +32,7 @@
android:layout_marginTop="10dp"
android:layout_marginEnd="14dp"
android:ellipsize="end"
android:importantForAccessibility="no"
android:maxLines="1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium"
@ -53,6 +55,7 @@
android:layout_marginTop="4dp"
android:layout_marginEnd="14dp"
android:ellipsize="end"
android:importantForAccessibility="no"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
@ -69,6 +72,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:importantForAccessibility="no"
android:lineSpacingMultiplier="1.1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_large"
@ -83,6 +87,7 @@
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:background="?attr/content_warning_button"
android:importantForAccessibility="no"
android:minWidth="160dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
@ -103,6 +108,7 @@
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:focusable="true"
android:importantForAccessibility="no"
android:lineSpacingMultiplier="1.1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_large"
@ -180,6 +186,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="4dp"
android:importantForAccessibility="noHideDescendants"
app:layout_constraintTop_toBottomOf="@id/card_view">
<com.keylesspalace.tusky.view.MediaPreviewImageView
@ -326,6 +333,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:drawablePadding="4dp"
android:importantForAccessibility="no"
android:textColor="?android:textColorTertiary"
android:textSize="?attr/status_text_medium"
app:layout_constraintLeft_toLeftOf="parent"
@ -339,6 +347,7 @@
android:layout_height="1dp"
android:layout_below="@id/status_timestamp_info"
android:layout_marginTop="6dp"
android:importantForAccessibility="no"
android:background="?android:attr/listDivider"
android:paddingStart="16dp"
android:paddingEnd="16dp"
@ -350,6 +359,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="?attr/selectableItemBackground"
android:importantForAccessibility="no"
android:padding="4dp"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="@id/status_info_divider"
@ -364,6 +374,7 @@
android:layout_marginStart="12dp"
android:layout_marginTop="6dp"
android:background="?attr/selectableItemBackground"
android:importantForAccessibility="no"
android:padding="4dp"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toEndOf="@id/status_reblogs"
@ -384,6 +395,7 @@
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="6dp"
android:importantForAccessibility="no"
android:background="?android:attr/listDivider"
android:paddingStart="16dp"
android:paddingEnd="16dp"
@ -397,6 +409,7 @@
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:contentDescription="@string/action_reply"
android:importantForAccessibility="no"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/status_reblog"
@ -411,6 +424,7 @@
android:layout_height="40dp"
android:clipToPadding="false"
android:contentDescription="@string/action_reblog"
android:importantForAccessibility="no"
android:padding="4dp"
app:layout_constraintEnd_toStartOf="@id/status_favourite"
app:layout_constraintStart_toEndOf="@id/status_reply"
@ -427,6 +441,7 @@
android:layout_height="40dp"
android:clipToPadding="false"
android:contentDescription="@string/action_favourite"
android:importantForAccessibility="no"
android:padding="4dp"
app:layout_constraintEnd_toStartOf="@id/status_more"
app:layout_constraintStart_toEndOf="@id/status_reblog"
@ -443,6 +458,7 @@
android:layout_width="32dp"
android:layout_height="32dp"
android:contentDescription="@string/action_more"
android:importantForAccessibility="no"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@id/status_reply"
app:layout_constraintEnd_toEndOf="parent"

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="action_expand_collapse_cw" type="id" />
<item name="action_reply" type="id" />
<item name="action_favourite" type="id" />
<item name="action_unfavourite" type="id" />
<item name="action_reblog" type="id" />
<item name="action_unreblog" type="id" />
<item name="action_open_profile" type="id" />
<item name="action_open_media_1" type="id" />
<item name="action_open_media_2" type="id" />
<item name="action_open_media_3" type="id" />
<item name="action_open_media_4" type="id" />
<item name="action_open_mention" type="id" />
<item name="action_expand_cw" type="id" />
<item name="action_collapse_cw" type="id" />
<item name="action_links" type="id" />
<item name="action_mentions" type="id" />
<item name="action_hashtags" type="id" />
<item name="action_open_reblogger" type="id" />
<item name="action_open_reblogged_by" type="id" />
<item name="action_open_faved_by" type="id" />
</resources>

View File

@ -67,7 +67,9 @@
<string name="action_quick_reply">Quick Reply</string>
<string name="action_reply">Reply</string>
<string name="action_reblog">Boost</string>
<string name="action_unreblog">Remove boost</string>
<string name="action_favourite">Favourite</string>
<string name="action_unfavourite">Remove favourite</string>
<string name="action_more">More</string>
<string name="action_compose">Compose</string>
<string name="action_login">Login with Mastodon</string>
@ -114,6 +116,17 @@
<string name="action_content_warning">Content warning</string>
<string name="action_emoji_keyboard">Emoji keyboard</string>
<string name="action_add_tab">Add Tab</string>
<string name="action_links">Links</string>
<string name="action_mentions">Mentions</string>
<string name="action_hashtags">Hashtags</string>
<string name="action_open_reblogger">Open boost author</string>
<string name="action_open_reblogged_by">Show boosts</string>
<string name="action_open_faved_by">Show favourites</string>
<string name="title_hashtags_dialog">Hashtags</string>
<string name="title_mentions_dialog">Mentions</string>
<string name="title_links_dialog">Links</string>
<string name="action_open_media_n">Open media #%d</string>
<string name="download_image">Downloading %1$s</string>
@ -387,4 +400,36 @@
<string name="max_tab_number_reached">maximum of %1$d tabs reached</string>
<string name="description_status_media">
Media: %s
</string>
<string name="description_status_cw">
Content warning: %s
</string>
<string name="description_status_media_no_description_placeholder">
No description
</string>
<string name="description_status_reblogged">
Reblogged
</string>
<string name="description_status_favourited">
Favourited
</string>
<string name="description_visiblity_public">
Public
</string>
<string name="description_visiblity_unlisted">
Unlisted
</string>
<string name="description_visiblity_private">
Followers
</string>
<string name="description_visiblity_direct">
Direct
</string>
<string name="description_status">
<!-- Display name, cw?, content?, relative date, reposted by?, reposted?, favorited?, username, media?; visibility, fav number?, reblog number?-->
%s; %s; %s, %s, %s; %s, %s, %s, %s; %s, %s, %s
</string>
</resources>