[go: nahoru, domu]

Change announcement on 'isPassword' toggle

This changes the announcement when we toggle the password visibility icon in the material TextField.
This change follows the View-based implementation: it sends the TYPE_VIEW_TEXT_SELECTION_CHANGED. In View there's another TYPE_VIEW_TEXT_SELECTION_CHANGED event with fromIndex=0 and toIndex=0 which most likely come from setting the span. We don't send this event in Compose (only when the real selection changes). Therefore to mirror the behavior we need to send it. As a result two TYPE_VIEW_TEXT_SELECTION_CHANGED events are sent - one with the cursor position at 0 and second with the correct position.

Bug: 247891690
Test: manually in demo with Talkback on
Test: new tests in AndroidComposeViewAccessibilityDelegateCompatTest

Change-Id: If75a1d48df37a0475367aacfca925d39084d38f1
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidAccessibilityTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidAccessibilityTest.kt
index 1b57cbe..2ee2e0c 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidAccessibilityTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidAccessibilityTest.kt
@@ -87,6 +87,7 @@
 import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat
 import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.ClassName
 import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.InvalidId
+import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.TextFieldClassName
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.platform.getAllUncoveredSemanticsNodesToMap
@@ -1408,6 +1409,7 @@
             textFieldNode.id,
             AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
         )
+        textEvent.className = TextFieldClassName
         textEvent.fromIndex = initialText.length
         textEvent.removedCount = 0
         textEvent.addedCount = text.length - initialText.length
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidComposeViewAccessibilityDelegateCompatTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidComposeViewAccessibilityDelegateCompatTest.kt
index f375f89..9e1503d 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidComposeViewAccessibilityDelegateCompatTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidComposeViewAccessibilityDelegateCompatTest.kt
@@ -66,6 +66,7 @@
 import androidx.compose.ui.semantics.liveRegion
 import androidx.compose.ui.semantics.onClick
 import androidx.compose.ui.semantics.onLongClick
+import androidx.compose.ui.semantics.password
 import androidx.compose.ui.semantics.pasteText
 import androidx.compose.ui.semantics.progressBarRangeInfo
 import androidx.compose.ui.semantics.role
@@ -1296,6 +1297,124 @@
         )
     }
 
+    @Test
+    fun passwordVisibilityToggle_fromInvisibleToVisible_doNotSendTextChangeEvent() {
+        sendTextSemanticsChangeEvent(oldNodePassword = true, newNodePassword = false)
+
+        verify(container, never()).requestSendAccessibilityEvent(
+            eq(androidComposeView),
+            argThat(
+                ArgumentMatcher {
+                    it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
+                }
+            )
+        )
+    }
+
+    @Test
+    fun passwordVisibilityToggle_fromVisibleToInvisible_doNotSendTextChangeEvent() {
+        sendTextSemanticsChangeEvent(oldNodePassword = false, newNodePassword = true)
+
+        verify(container, never()).requestSendAccessibilityEvent(
+            eq(androidComposeView),
+            argThat(
+                ArgumentMatcher {
+                    it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
+                }
+            )
+        )
+    }
+
+    @Test
+    fun passwordVisibilityToggle_fromInvisibleToVisible_sendTwoSelectionEvents() {
+        sendTextSemanticsChangeEvent(oldNodePassword = true, newNodePassword = false)
+
+        verify(container, times(2)).requestSendAccessibilityEvent(
+            eq(androidComposeView),
+            argThat(
+                ArgumentMatcher {
+                    it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED
+                }
+            )
+        )
+    }
+
+    @Test
+    fun passwordVisibilityToggle_fromVisibleToInvisible_sendTwoSelectionEvents() {
+        sendTextSemanticsChangeEvent(oldNodePassword = false, newNodePassword = true)
+
+        verify(container, times(2)).requestSendAccessibilityEvent(
+            eq(androidComposeView),
+            argThat(
+                ArgumentMatcher {
+                    it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED
+                }
+            )
+        )
+    }
+
+    @Test
+    fun textChanged_sendTextChangeEvent() {
+        sendTextSemanticsChangeEvent(oldNodePassword = false, newNodePassword = false)
+
+        verify(container, times(1)).requestSendAccessibilityEvent(
+            eq(androidComposeView),
+            argThat(
+                ArgumentMatcher {
+                    it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
+                }
+            )
+        )
+    }
+
+    @Test
+    fun textChanged_passwordNode_sendTextChangeEvent() {
+        sendTextSemanticsChangeEvent(oldNodePassword = true, newNodePassword = true)
+
+        verify(container, times(1)).requestSendAccessibilityEvent(
+            eq(androidComposeView),
+            argThat(
+                ArgumentMatcher {
+                    it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
+                }
+            )
+        )
+    }
+
+    private fun sendTextSemanticsChangeEvent(oldNodePassword: Boolean, newNodePassword: Boolean) {
+        val nodeId = 1
+        val oldTextNode = createSemanticsNodeWithProperties(nodeId, true) {
+            setText { true }
+            if (oldNodePassword) password()
+            textSelectionRange = TextRange(4)
+            editableText = AnnotatedString(
+                when {
+                    oldNodePassword && !newNodePassword -> "****"
+                    !oldNodePassword && newNodePassword -> "1234"
+                    !oldNodePassword && !newNodePassword -> "1234"
+                    else -> "1234"
+                }
+            )
+        }
+        accessibilityDelegate.previousSemanticsNodes[nodeId] =
+            AndroidComposeViewAccessibilityDelegateCompat.SemanticsNodeCopy(oldTextNode, mapOf())
+
+        val newTextNode = createSemanticsNodeWithAdjustedBoundsWithProperties(nodeId, true) {
+            setText { true }
+            if (newNodePassword) password()
+            textSelectionRange = TextRange(4)
+            editableText = AnnotatedString(
+                when {
+                    oldNodePassword && !newNodePassword -> "1234"
+                    !oldNodePassword && newNodePassword -> "****"
+                    !oldNodePassword && !newNodePassword -> "1235"
+                    else -> "1235"
+                }
+            )
+        }
+        accessibilityDelegate.sendSemanticsPropertyChangeEvents(mapOf(nodeId to newTextNode))
+    }
+
     @OptIn(ExperimentalComposeUiApi::class)
     private fun createSemanticsNodeWithProperties(
         id: Int,
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
index ffb5830..facce63 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
@@ -124,6 +124,8 @@
         /** Virtual node identifier value for invalid nodes. */
         const val InvalidId = Integer.MIN_VALUE
         const val ClassName = "android.view.View"
+        const val TextFieldClassName = "android.widget.EditText"
+        const val TextClassName = "android.widget.TextView"
         const val LogTag = "AccessibilityDelegate"
         const val ExtraDataTestTagKey = "androidx.compose.ui.semantics.testTag"
 
@@ -291,7 +293,7 @@
      */
     @VisibleForTesting
     internal class SemanticsNodeCopy(
-        semanticsNode: SemanticsNode,
+        val semanticsNode: SemanticsNode,
         currentSemanticsNodes: Map<Int, SemanticsNodeWithAdjustedBounds>
     ) {
         val unmergedConfig = semanticsNode.unmergedConfig
@@ -523,10 +525,10 @@
             }
         }
         if (semanticsNode.isTextField) {
-            info.className = "android.widget.EditText"
+            info.className = TextFieldClassName
         }
         if (semanticsNode.config.contains(SemanticsProperties.Text)) {
-            info.className = "android.widget.TextView"
+            info.className = TextClassName
         }
 
         info.packageName = view.context.packageName
@@ -1200,7 +1202,7 @@
         fromIndex: Int?,
         toIndex: Int?,
         itemCount: Int?,
-        text: String?
+        text: CharSequence?
     ): AccessibilityEvent {
         return createEvent(
             virtualViewId,
@@ -2042,10 +2044,12 @@
                         )
                     }
                     SemanticsProperties.EditableText -> {
-                        // TODO(b/160184953) Add test for SemanticsProperty Text change event
                         if (newNode.isTextField) {
+
                             val oldText = oldNode.unmergedConfig.getTextForTextField() ?: ""
                             val newText = newNode.unmergedConfig.getTextForTextField() ?: ""
+                            val trimmedNewText = trimToSize(newText, ParcelSafeTextLength)
+
                             var startCount = 0
                             // endCount records how many characters are the same from the end.
                             var endCount = 0
@@ -2070,16 +2074,53 @@
                             }
                             val removedCount = oldTextLen - endCount - startCount
                             val addedCount = newTextLen - endCount - startCount
-                            val textChangeEvent = createEvent(
-                                semanticsNodeIdToAccessibilityVirtualNodeId(id),
-                                AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
-                            )
-                            textChangeEvent.fromIndex = startCount
-                            textChangeEvent.removedCount = removedCount
-                            textChangeEvent.addedCount = addedCount
-                            textChangeEvent.beforeText = oldText
-                            textChangeEvent.text.add(trimToSize(newText, ParcelSafeTextLength))
-                            sendEvent(textChangeEvent)
+
+                            // (b/247891690) We won't send a text change event when we only toggle
+                            // the password visibility of the node
+                            val becamePasswordNode = oldNode.semanticsNode.isTextField &&
+                                !oldNode.semanticsNode.isPassword && newNode.isPassword
+                            val becameNotPasswordNode = oldNode.semanticsNode.isTextField &&
+                                oldNode.semanticsNode.isPassword && !newNode.isPassword
+                            val event = if (becamePasswordNode || becameNotPasswordNode) {
+                                // (b/247891690) password visibility toggle is handled by a
+                                // selection event. Because internally Talkback already has the
+                                // correct cursor position, there will be no announcement.
+                                // Therefore we first send the "cursor reset" event with the
+                                // selection at (0, 0) and right after that we will send the event
+                                // with the correct cursor position. This behaves similarly to the
+                                // View-based material EditText which also sends two selection
+                                // events
+                                createTextSelectionChangedEvent(
+                                    virtualViewId = semanticsNodeIdToAccessibilityVirtualNodeId(id),
+                                    fromIndex = 0,
+                                    toIndex = 0,
+                                    itemCount = newTextLen,
+                                    text = trimmedNewText
+                                )
+                            } else {
+                                createEvent(
+                                    semanticsNodeIdToAccessibilityVirtualNodeId(id),
+                                    AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
+                                ).apply {
+                                    this.fromIndex = startCount
+                                    this.removedCount = removedCount
+                                    this.addedCount = addedCount
+                                    this.beforeText = oldText
+                                    this.text.add(trimmedNewText)
+                                }
+                            }
+                            event.className = TextFieldClassName
+                            sendEvent(event)
+
+                            // (b/247891690) second event with the correct cursor position (see
+                            // comment above for more details)
+                            if (becamePasswordNode || becameNotPasswordNode) {
+                                val textRange =
+                                    newNode.unmergedConfig[SemanticsProperties.TextSelectionRange]
+                                event.fromIndex = textRange.start
+                                event.toIndex = textRange.end
+                                sendEvent(event)
+                            }
                         } else {
                             sendEventForVirtualView(
                                 semanticsNodeIdToAccessibilityVirtualNodeId(id),