com.deque.axe.android.AxeView.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of axe-devtools-android-core Show documentation
Show all versions of axe-devtools-android-core Show documentation
The Axe Devtools Android Core Library
package com.deque.axe.android
import com.deque.axe.android.colorcontrast.AxeColor
import com.deque.axe.android.constants.AndroidClassNames
import com.deque.axe.android.constants.Constants
import com.deque.axe.android.utils.AxeTextUtils
import com.deque.axe.android.utils.AxeTree
import com.deque.axe.android.utils.AxeTree.CallBackResponse
import com.deque.axe.android.utils.SuppressFBWarnings
import com.deque.axe.android.wrappers.AxeRect
import java.util.concurrent.atomic.AtomicBoolean
open class AxeView private constructor(
/**
* A direct copy of the associated Android property encapsulated in an Axe wrapper.
*/
@JvmField val boundsInScreen: AxeRect,
/**
* Direct copy of the associated Java Property.
*/
@JvmField val className: String?,
/**
* Direct copy of the associated Android Property.
*/
@JvmField val contentDescription: String?,
/**
* Whether or not the view would be focused by Assistive Technologies.
*/
@JvmField val isAccessibilityFocusable: Boolean,
/**
* Whether or not the view responds to Click actions.
*/
@JvmField val isClickable: Boolean,
/**
* True if view interaction is enabled.
*/
@JvmField val isEnabled: Boolean,
/**
* Direct copy of the associated Android Property.
*/
@JvmField val isImportantForAccessibility: Boolean,
/**
* The Children of this view as AxeView objects.
*/
@JvmField var children: List,
/**
* The AxeView of the Label that is acting as the Name for this View.
*/
@JvmField
val labeledBy: AxeView?,
/**
* The packageName that the View belongs to.
* FIXME: Make non transient before a 1.0 release.
*/
@Transient
@JvmField val packageName: String? = "",
/**
* Direct copy of the associated Android property.
*/
@Transient
@JvmField val paneTitle: String? = "",
/**
* Direct copy of the associated Android Property.
*/
@JvmField
val text: String?,
/**
* Direct copy of the associated Android Property.
*/
@JvmField val viewIdResourceName: String?,
/**
* Direct copy of hint text for views where text can be entered.
*/
@JvmField val hintText: String?,
/**
* Direct copy of value.
*/
@JvmField val value: String?,
/**
* True if the view overrides AccessibilityDelegate.
*/
@JvmField
val overridesAccessibilityDelegate: Boolean,
/**
* True if the view is visible to the user.
*/
@JvmField val isVisibleToUser: Boolean,
/**
* Direct copy of the associated Android Property.
*/
@JvmField val visibility: Int,
/**
* Direct copy of the associated Android Property.
*/
@JvmField
val measuredHeight: Int,
/**
* Direct copy of the associated Android Property.
*/
@JvmField
val measuredWidth: Int,
/**
* Text Color property encapsulated in an Axe wrapper.
*/
@JvmField
val textColor: AxeColor?,
/**
* True if the view is a ComposeView or child of a ComposeView.
*/
@JvmField
val isComposeView: Boolean = false,
/**
* Set of ignored rules.
*/
@JvmField
val ignoreRules: Set = setOf()
) : AxeTree {
/**
* A unique Identifier for a given view... conflicts possible but unlikely.
*/
val axeViewId: String
get() = getViewId()
/**
* Library of calculated props name, role, state, value.
*/
val calculatedProps: CalculatedProps
init {
setContentView(viewIdResourceName, boundsInScreen)
calculatedProps = calculateProps()
// This should be the last thing we do in case we decide parent/children relationships
// contribute to ID calculation.
// axeViewId = getViewId()
}
interface Builder {
fun boundsInScreen(): AxeRect
fun className(): String?
fun contentDescription(): String?
fun isAccessibilityFocusable(): Boolean
fun isClickable(): Boolean
fun isEnabled(): Boolean
fun isImportantForAccessibility(): Boolean
fun labeledBy(): AxeView?
fun packageName(): String?
fun paneTitle(): String?
fun text(): String?
fun viewIdResourceName(): String?
fun hintText(): String?
fun value(): String?
fun children(): List
fun overridesAccessibilityDelegate(): Boolean
fun isVisibleToUser(): Boolean
fun visibility(): Int
fun measuredHeight(): Int
fun measuredWidth(): Int
fun textColor(): AxeColor?
fun isComposeView(): Boolean
fun ignoreRules(): Set
fun build(): AxeView {
return AxeView(this)
}
}
/**
* Construct an AxeView.
* @param builder An object that implements the AxeView.Builder interface.
*/
constructor(
builder: Builder
) : this(
boundsInScreen = builder.boundsInScreen(),
className = builder.className(),
contentDescription = builder.contentDescription(),
isAccessibilityFocusable = builder.isAccessibilityFocusable(),
isClickable = builder.isClickable(),
isEnabled = builder.isEnabled(),
isImportantForAccessibility = builder.isImportantForAccessibility(),
labeledBy = builder.labeledBy(),
packageName = builder.packageName(),
paneTitle = builder.paneTitle(),
text = builder.text(),
viewIdResourceName = builder.viewIdResourceName(),
hintText = builder.hintText(),
value = builder.value(),
children = builder.children(),
overridesAccessibilityDelegate = builder.overridesAccessibilityDelegate(),
isVisibleToUser = builder.isVisibleToUser(),
visibility = builder.visibility(),
measuredHeight = builder.measuredHeight(),
measuredWidth = builder.measuredWidth(),
textColor = builder.textColor(),
isComposeView = builder.isComposeView(),
ignoreRules = builder.ignoreRules()
)
/**
* Recurse through the view hierarchy and grab the package name of the first
* non Android System UI view.
*
* @return A non Android System UI packageName.
*/
fun appIdentifier(): String {
val result: StringBuilder = StringBuilder()
forEachRecursive { instance: AxeView? ->
result.setLength(0)
result.append(instance?.packageName)
if (instance?.className?.endsWith("ContentFrameLayout") == true) {
return@forEachRecursive CallBackResponse.STOP
} else {
return@forEachRecursive CallBackResponse.CONTINUE
}
}
return result.toString()
}
/**
* Gets speakable text of the control. Digs down into child views to see what their speakable
* text is as well.
*
* @return The speakable text of the control and its children.
*/
fun speakableTextRecursive(): String? {
val result: StringBuilder = StringBuilder()
val allAreNull = AtomicBoolean(true)
forEachRecursive { instance: AxeView? ->
if (isChildSpeakableTextIgnoredByTalkback) {
result.append(instance?.speakableText())
allAreNull.set(false)
return@forEachRecursive CallBackResponse.SKIP_BRANCH
}
val speakableText: String? = instance?.speakableText()
if (!AxeTextUtils.isNullOrEmpty(speakableText)) {
result.append(instance?.speakableText()).append(" ")
}
if (speakableText != null) {
allAreNull.set(false)
}
CallBackResponse.CONTINUE
}
return if (allAreNull.get()) null else result.toString()
}
open fun speakableText(): String? {
return if (text == null || text.isEmpty()) contentDescription else text
}
open fun parentSpeakableText(): String? {
return ""
}
open fun parentViewResourceId(): String? {
return ""
}
open fun getId(): Int {
return -1
}
open fun getParent(): Any? {
return null
}
fun speakableTextOfLabeledBy(): String? {
return labeledBy?.speakableText()
}
/**
* Checks if the text has Operating System Text or is Null.
*
* @param text to check.
* @return true if text is Operating System Text or is Null.
*/
fun hasOperatingSystemTextOnlyOrIsNull(text: String?): Boolean {
return if (text == null) {
true
} else {
text.equals("on", ignoreCase = true) || text.equals("off", ignoreCase = true)
}
}
override fun getTreeChildren(): List {
return children
}
override fun getTreeNode(): AxeView {
return this
}
override fun getNodeId(): String {
return axeViewId
}
// No longer populating axeViewId via hashCode - unstable method to generate ID
private fun getViewId(): String {
return hashCode().toString()
}
override fun hashCode(): Int {
var result = boundsInScreen.hashCode()
result = 31 * result + (className?.hashCode() ?: 0)
result = 31 * result + (contentDescription?.hashCode() ?: 0)
result = 31 * result + isAccessibilityFocusable.hashCode()
result = 31 * result + isClickable.hashCode()
result = 31 * result + children.hashCode()
result = 31 * result + isEnabled.hashCode()
result = 31 * result + isImportantForAccessibility.hashCode()
result = 31 * result + (labeledBy?.hashCode() ?: 0)
result = 31 * result + (packageName?.hashCode() ?: 0)
result = 31 * result + (paneTitle?.hashCode() ?: 0)
result = 31 * result + (text?.hashCode() ?: 0)
result = 31 * result + (viewIdResourceName?.hashCode() ?: 0)
result = 31 * result + (hintText?.hashCode() ?: 0)
result = 31 * result + (value?.hashCode() ?: 0)
result = 31 * result + overridesAccessibilityDelegate.hashCode()
result = 31 * result + isVisibleToUser.hashCode()
result = 31 * result + visibility
result = 31 * result + measuredHeight
result = 31 * result + measuredWidth
result = 31 * result + (textColor?.hashCode() ?: 0)
result = 31 * result + isComposeView.hashCode()
result = 31 * result + ignoreRules.hashCode()
return result
}
interface Matcher {
fun matches(view: AxeView?): Boolean
}
/**
* Find all AxeView objects in the hierarchy that match.
*
* @param matcher A matcher function.
* @return The list of views that match.
*/
fun query(matcher: Matcher): List {
val results: ArrayList = ArrayList()
forEachRecursive { instance: AxeView? ->
try {
if (instance != null && matcher.matches(instance)) {
results.add(instance)
}
} catch (e: Exception) {
e.printStackTrace()
}
CallBackResponse.CONTINUE
}
return results
}
/**
* Find first match on AxeView object in the hierarchy and ignore axe_overlay_view.
*
* @param matcher A matcher function.
* @return AxeView on first match.
*/
fun queryFirstMatch(matcher: Matcher): AxeView? {
val results: ArrayList = ArrayList()
forEachRecursive { instance ->
try {
if (instance?.viewIdResourceName?.endsWith(":id/axe_overlay_view") == true) {
return@forEachRecursive CallBackResponse.SKIP_BRANCH
}
if (instance != null && matcher.matches(instance)) {
results.add(instance)
return@forEachRecursive CallBackResponse.STOP
}
} catch (e: Exception) {
e.printStackTrace()
}
CallBackResponse.CONTINUE
}
return if (results.size > 0) results[0] else null
}
private fun calculateProps(): CalculatedProps {
val labelText: String = if (labeledBy == null) "" else (labeledBy.text) ?: ""
val axePropCalculator = AxePropCalculator(
text,
contentDescription,
labelText,
value,
isEnabled,
className,
hintText,
isVisibleToUser
)
return axePropCalculator.calculatedProps
}
private val isContentView: Boolean
get() {
return (viewIdResourceName != null
&& (viewIdResourceName.endsWith("android:id/content") && children.isNotEmpty()))
}
private fun getContentView(): AxeView? {
val axeViewList = query(object : Matcher {
override fun matches(view: AxeView?): Boolean {
return (view?.viewIdResourceName != null
&& view.viewIdResourceName.endsWith("android:id/content"))
}
})
return if (axeViewList.isEmpty()) {
null
} else {
axeViewList[0]
}
}
private val isChildSpeakableTextIgnoredByTalkback: Boolean
get() = (!AxeTextUtils.isNullOrEmpty(contentDescription) && !isAccessibilityFocusable)
|| (!AxeTextUtils.isNullOrEmpty(text) && !isAccessibilityFocusable)
private fun findFirstViewResId(contentView: AxeView): String? {
val axeViewResId = contentView.queryFirstMatch(object : Matcher {
override fun matches(view: AxeView?): Boolean {
return (view?.viewIdResourceName != null
&& view.viewIdResourceName.contains(":id/")
&& !view.viewIdResourceName.endsWith("android:id/content"))
}
})
return axeViewResId?.viewIdResourceName
}
private fun findFirstWidgetClassName(contentView: AxeView): String? {
val axeViewWidgetClassName = contentView.queryFirstMatch(object : Matcher {
override fun matches(view: AxeView?): Boolean {
return (view?.viewIdResourceName != null && view.className != null &&
!view.viewIdResourceName.endsWith("android:id/content")
&& (view.className.contains("widget")
|| view.className.contains("material")))
}
})
if (axeViewWidgetClassName != null) {
val className = axeViewWidgetClassName.className
if (className?.contains(".") == true) {
val words = className.split(".").toTypedArray()
return words[words.size - 1]
}
return className
}
return null
}
/**
* Calculate the screen title for a hierarchy.
*
* @return The screen title.
*/
val screenTitle: String
get() {
val contentView: AxeView? = getContentView()
if (contentView != null && contentView.isContentView) {
val viewResId: String? = findFirstViewResId(contentView)
if (viewResId != null) {
return viewResId
}
val widgetClassName: String? = findFirstWidgetClassName(contentView)
if (widgetClassName != null) {
return widgetClassName
}
}
return Constants.DEFAULT_SCREEN_TITLE
}
/**
* Returns true if the view is Rendered on screen.
*
* @param dpi device dots per inch.
* @param height device height.
* @param width device width.
*/
fun isRendered(dpi: Float, height: Long, width: Long): Boolean {
return (dpi <= 0) || (height < 0) || (width < 0)
}
/**
* Returns true if the view is created in hierarchy but not visible on the screen.
*
* @param screenHeight device height.
* @param screenWidth device width.
*/
fun isOffScreen(screenHeight: Int, screenWidth: Int): Boolean {
if (screenHeight > 0 && screenWidth > 0) {
return (boundsInScreen.top < 0
) || (boundsInScreen.left < 0
) || (boundsInScreen.bottom > screenHeight
) || (boundsInScreen.right > screenWidth)
}
return false
}
/**
* Returns true if only part of the view is visible on screen.
*
* @param screenHeight device height.
* @param screenWidth device width.
*/
@SuppressFBWarnings
fun isPartiallyVisible(screenHeight: Int, screenWidth: Int): Boolean {
if ((screenHeight > 0) && (screenWidth > 0) && (contentViewAxeRect != null)) {
if ((measuredHeight > boundsInScreen.height()
|| measuredWidth > boundsInScreen.width())) {
return true
}
if (measuredWidth == 0 && measuredHeight == 0) {
return (boundsInScreen.top <= contentViewAxeRect!!.top
) || (boundsInScreen.left <= 0
) || (boundsInScreen.bottom >= contentViewAxeRect!!.bottom
) || (boundsInScreen.right >= screenWidth)
}
}
return false
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AxeView
if (boundsInScreen != other.boundsInScreen) return false
if (className != other.className) return false
if (contentDescription != other.contentDescription) return false
if (isAccessibilityFocusable != other.isAccessibilityFocusable) return false
if (isClickable != other.isClickable) return false
if (isEnabled != other.isEnabled) return false
if (isImportantForAccessibility != other.isImportantForAccessibility) return false
if (labeledBy != other.labeledBy) return false
if (packageName != other.packageName) return false
if (paneTitle != other.paneTitle) return false
if (text != other.text) return false
if (viewIdResourceName != other.viewIdResourceName) return false
if (hintText != other.hintText) return false
if (value != other.value) return false
if (overridesAccessibilityDelegate != other.overridesAccessibilityDelegate) return false
if (isVisibleToUser != other.isVisibleToUser) return false
if (visibility != other.visibility) return false
if (measuredHeight != other.measuredHeight) return false
if (measuredWidth != other.measuredWidth) return false
if (textColor != other.textColor) return false
if (isComposeView != other.isComposeView) return false
if (ignoreRules != other.ignoreRules) return false
if (calculatedProps != other.calculatedProps) return false
return true
}
companion object {
/**
* Maintains a copy of Content View Axe Rect.
*/
var contentViewAxeRect: AxeRect? = null
fun classNameIsOfType(
fullClassName: String,
@AndroidClassNames viewClass: String
): Boolean {
return viewClass.equals(fullClassName, ignoreCase = true)
}
private fun setContentView(viewIdResourceName: String?, boundsInScreen: AxeRect) {
if (viewIdResourceName != null && (viewIdResourceName == "android:id/content")) {
contentViewAxeRect = boundsInScreen
}
}
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy