All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.deque.axe.android.AxeView.kt Maven / Gradle / Ivy

The newest version!
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 com.deque.axe.android.wrappers.TextBoundsInScreen
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(),

        /**
         * List of machine learning identified bounds of text.
         */
        @JvmField
        val mlKitIdentifiedTextAndBoundsInScreen: List

        ) : 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 mlKitIdentifiedTextAndBoundsInScreen(): List
        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(),
            mlKitIdentifiedTextAndBoundsInScreen = builder.mlKitIdentifiedTextAndBoundsInScreen()
    )

    /**
     * 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? {
        return queryFirstMatch(object : Matcher {
            override fun matches(view: AxeView?): Boolean {
                return (view?.viewIdResourceName != null
                        && view.viewIdResourceName.endsWith("android:id/content"))
            }
        })
    }

    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