Skip to content

Commit b36e5db

Browse files
authored
Merge pull request #23 from snabble/scanner_accessibility
Scanner accessibility
2 parents cb72d4c + 44e655c commit b36e5db

26 files changed

+760
-428
lines changed
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package io.snabble.sdk.ui
2+
3+
import android.accessibilityservice.AccessibilityServiceInfo
4+
import android.annotation.SuppressLint
5+
import android.content.Context
6+
import android.view.View
7+
import android.view.ViewGroup
8+
import android.view.accessibility.AccessibilityEvent
9+
import android.view.accessibility.AccessibilityManager
10+
import android.view.accessibility.AccessibilityNodeInfo
11+
import android.widget.TextView
12+
import androidx.annotation.IntDef
13+
import androidx.annotation.StringRes
14+
import androidx.core.view.AccessibilityDelegateCompat
15+
import androidx.core.view.ViewCompat
16+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
17+
import androidx.core.view.isVisible
18+
import io.snabble.sdk.Snabble
19+
20+
typealias AccessibilityEventListener = (host: ViewGroup?,
21+
child: View?,
22+
event: AccessibilityEvent) -> Any?
23+
24+
class AccessibilityToolBox(private val target: View): AccessibilityDelegateCompat() {
25+
private val eventListeners = mutableMapOf<Int, AccessibilityEventListener>()
26+
private var clickActionLabel: String? = null
27+
private var longClickActionLabel: String? = null
28+
val isTalkBackActive
29+
get() = target.context.isTalkBackActive
30+
var phoneticDict: Map<String, String> = emptyMap()
31+
set(value) {
32+
field = value
33+
(target.contentDescription?.toString() ?: (target as? TextView)?.text?.toString())?.let { currentValue ->
34+
var newValue = currentValue
35+
field.entries.forEach { (from, to) ->
36+
newValue = newValue.replace(from, to)
37+
}
38+
if (newValue != currentValue) {
39+
target.contentDescription = newValue
40+
}
41+
}
42+
}
43+
private var onInitializeAccessibilityNodeInfo: ((info: AccessibilityNodeInfoCompat)-> Unit)? = null
44+
fun onInitializeAccessibilityNodeInfo(block: (info: AccessibilityNodeInfoCompat)-> Unit) {
45+
onInitializeAccessibilityNodeInfo = block
46+
}
47+
48+
override fun onRequestSendAccessibilityEvent(
49+
host: ViewGroup?,
50+
child: View?,
51+
event: AccessibilityEvent?
52+
): Boolean {
53+
val listener = event?.let { eventListeners[event.eventType] }
54+
listener?.invoke(host, child, event)
55+
return super.onRequestSendAccessibilityEvent(host, child, event)
56+
}
57+
58+
override fun onInitializeAccessibilityNodeInfo(
59+
host: View,
60+
info: AccessibilityNodeInfoCompat
61+
) {
62+
super.onInitializeAccessibilityNodeInfo(host, info)
63+
clickActionLabel?.let {
64+
info.addAction(
65+
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
66+
AccessibilityNodeInfoCompat.ACTION_CLICK,
67+
clickActionLabel
68+
)
69+
)
70+
}
71+
longClickActionLabel?.let {
72+
info.addAction(
73+
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
74+
AccessibilityNodeInfoCompat.ACTION_LONG_CLICK,
75+
longClickActionLabel
76+
)
77+
)
78+
}
79+
onInitializeAccessibilityNodeInfo?.invoke(info)
80+
}
81+
82+
fun onAccessibilityEvent(@EventType event: Int, block: AccessibilityEventListener) {
83+
eventListeners[event] = block
84+
}
85+
86+
fun setLongClickAction(label: String, onLongClick: (() -> Any)? = null) {
87+
longClickActionLabel = label
88+
onLongClick?.let {
89+
target.setOnLongClickListener {
90+
onLongClick()
91+
true
92+
}
93+
}
94+
}
95+
96+
fun setLongClickAction(@StringRes action: Int, onLongClick: (() -> Any)? = null) =
97+
setLongClickAction(target.context.getString(action), onLongClick)
98+
99+
fun setClickAction(label: String, onClick: (() -> Any)? = null) {
100+
clickActionLabel = label
101+
onClick?.let {
102+
target.setOnClickListener {
103+
onClick()
104+
}
105+
}
106+
}
107+
108+
fun setClickAction(@StringRes action: Int, onLongClick: (() -> Any)? = null) =
109+
setClickAction(target.context.getString(action), onLongClick)
110+
}
111+
112+
@SuppressLint("InlinedApi")
113+
@IntDef(
114+
flag = true,
115+
value = [
116+
AccessibilityEvent.TYPE_VIEW_CLICKED, AccessibilityEvent.TYPE_VIEW_LONG_CLICKED,
117+
AccessibilityEvent.TYPE_VIEW_SELECTED, AccessibilityEvent.TYPE_VIEW_FOCUSED,
118+
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED,
119+
AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED, AccessibilityEvent.TYPE_VIEW_HOVER_ENTER,
120+
AccessibilityEvent.TYPE_VIEW_HOVER_EXIT, AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_START,
121+
AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_END, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
122+
AccessibilityEvent.TYPE_VIEW_SCROLLED, AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED,
123+
AccessibilityEvent.TYPE_ANNOUNCEMENT, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED,
124+
AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED,
125+
AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY,
126+
AccessibilityEvent.TYPE_GESTURE_DETECTION_START, AccessibilityEvent.TYPE_GESTURE_DETECTION_END,
127+
AccessibilityEvent.TYPE_TOUCH_INTERACTION_START, AccessibilityEvent.TYPE_TOUCH_INTERACTION_END,
128+
AccessibilityEvent.TYPE_WINDOWS_CHANGED, AccessibilityEvent.TYPE_VIEW_CONTEXT_CLICKED,
129+
AccessibilityEvent.TYPE_ASSIST_READING_CONTEXT
130+
]
131+
)
132+
@Retention(AnnotationRetention.SOURCE)
133+
annotation class EventType
134+
135+
fun View.accessibility(block: AccessibilityToolBox.() -> Any) {
136+
var toolbox = getTag(R.id.snabble_accessibility_toolbox) as? AccessibilityToolBox
137+
if (toolbox == null) {
138+
toolbox = AccessibilityToolBox(this)
139+
setTag(R.id.snabble_accessibility_toolbox, toolbox)
140+
ViewCompat.setAccessibilityDelegate(this, toolbox)
141+
}
142+
block(toolbox)
143+
}
144+
145+
fun View.setClickDescription(stringId: Int, vararg formatArgs: Any) {
146+
setClickDescription(context.getString(stringId, formatArgs))
147+
}
148+
149+
fun View.setClickDescription(description: String) {
150+
ViewCompat.setAccessibilityDelegate(this, object : AccessibilityDelegateCompat() {
151+
override fun onInitializeAccessibilityNodeInfo(v: View, info: AccessibilityNodeInfoCompat) {
152+
super.onInitializeAccessibilityNodeInfo(v, info)
153+
info.addAction(
154+
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
155+
AccessibilityNodeInfoCompat.ACTION_CLICK,
156+
description
157+
)
158+
)
159+
}
160+
})
161+
}
162+
163+
fun TextView.cleanUpDescription() {
164+
contentDescription = text.toString().replace("...", "").replace("snabble", "snäbble")
165+
}
166+
167+
val Context.isTalkBackActive: Boolean
168+
get() {
169+
val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
170+
val voiceServices = am?.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_SPOKEN).orEmpty()
171+
val isTouchExplorationEnabled = am?.isTouchExplorationEnabled ?: false
172+
return voiceServices.isNotEmpty() && isTouchExplorationEnabled
173+
}
174+
175+
fun View.focusForAccessibility() {
176+
performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null)
177+
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED)
178+
}
179+
180+
fun orderViewsForAccessibility(vararg views: View?) {
181+
views
182+
.filterNotNull()
183+
.filter { it.isVisible }
184+
.forEachWindow { before, current, after ->
185+
ViewCompat.setAccessibilityDelegate(current, object : AccessibilityDelegateCompat() {
186+
override fun onInitializeAccessibilityNodeInfo(
187+
host: View,
188+
info: AccessibilityNodeInfoCompat
189+
) {
190+
super.onInitializeAccessibilityNodeInfo(host, info)
191+
info.setTraversalBefore(after)
192+
info.setTraversalAfter(before)
193+
}
194+
})
195+
}
196+
}
197+
198+
/**
199+
* Iterate over an Iterable the iterator param last can be only `null` when the size is 1. The last
200+
* parameter can only be `null` when the size is less then 2, otherwise both params are not `null`.
201+
*/
202+
fun <T> Iterable<T>.forEachWindow(iterator: (last: T?, current: T, next: T?) -> Unit) {
203+
val list = toList()
204+
when {
205+
list.size == 1 ->
206+
iterator(null, first(), null)
207+
list.size == 2 ->
208+
iterator(first(), last(), null)
209+
list.size >= 2 -> {
210+
for (i in 1..count() - 2) {
211+
iterator(list[i - 1], list[i], list[i + 1])
212+
}
213+
}
214+
}
215+
}
216+
217+
object AccessibilityPreferences {
218+
private const val KEY_SUPPRESS_SCANNER_HINT = "suppress_scanner_hint"
219+
private val sharedPreferences = Snabble.application.getSharedPreferences("accessibility", Context.MODE_PRIVATE)
220+
var suppressScannerHint: Boolean
221+
get() = sharedPreferences.getBoolean(KEY_SUPPRESS_SCANNER_HINT, false)
222+
set(seen) {
223+
sharedPreferences
224+
.edit()
225+
.putBoolean(KEY_SUPPRESS_SCANNER_HINT, seen)
226+
.apply()
227+
}
228+
}

ui/src/main/java/io/snabble/sdk/ui/cart/CheckoutBar.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import io.snabble.sdk.Snabble
2626
import io.snabble.sdk.ui.Keyguard
2727
import io.snabble.sdk.ui.R
2828
import io.snabble.sdk.ui.SnabbleUI
29+
import io.snabble.sdk.ui.accessibility
2930
import io.snabble.sdk.ui.checkout.CheckoutActivity
3031
import io.snabble.sdk.ui.payment.PaymentInputViewHelper
3132
import io.snabble.sdk.ui.payment.SEPALegalInfoHelper
@@ -170,6 +171,10 @@ open class CheckoutBar @JvmOverloads constructor(
170171
val isHidden = project.paymentMethodDescriptors.size == 1 && hasNoPaymentMethods
171172
paymentSelector.isVisible = !isHidden
172173
paymentIcon.setImageResource(entry.iconResId)
174+
paymentSelectorButton.contentDescription = resources.getString(R.string.Snabble_Shoppingcart_Accessibility_paymentMethod, entry.text)
175+
paymentSelectorButton.accessibility {
176+
setClickAction(R.string.Snabble_Shoppingcart_buyProducts_selectPaymentMethod)
177+
}
173178
}
174179
}
175180

ui/src/main/java/io/snabble/sdk/ui/cart/PaymentSelectionDialogFragment.java

Lines changed: 0 additions & 127 deletions
This file was deleted.

0 commit comments

Comments
 (0)