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:
parent
df8c78c81a
commit
479d210e64
15 changed files with 646 additions and 78 deletions
|
@ -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)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue