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 fragmentmain
parent
244a478eb5
commit
a9524508e6
@ -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) |
||||||
|
} |
@ -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> |
Loading…
Reference in new issue