From 9a499608a21f87939f3541e67a7da034edf0af39 Mon Sep 17 00:00:00 2001
From: ialokim <ialokim@mailbox.org>
Date: Wed, 22 Jun 2022 16:13:24 +0200
Subject: [PATCH] expose text field labels to screen-reader

---
 .../androidx/compose/material/TextFieldExt.kt | 35 ++++++++++---------
 .../briar/desktop/contact/SearchTextField.kt  |  5 +--
 .../contact/add/remote/AddContactDialog.kt    |  8 +++--
 .../desktop/conversation/ConversationInput.kt |  2 +-
 .../briar/desktop/login/LoginScreen.kt        |  4 ++-
 .../briar/desktop/login/RegistrationScreen.kt |  8 +++--
 .../desktop/settings/ChangePasswordDialog.kt  |  4 ++-
 .../briar/desktop/utils/AccessibilityUtils.kt | 33 +++++++++++++++++
 .../resources/strings/BriarDesktop.properties |  3 +-
 9 files changed, 74 insertions(+), 28 deletions(-)
 create mode 100644 briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/utils/AccessibilityUtils.kt

diff --git a/briar-desktop/src/main/kotlin/androidx/compose/material/TextFieldExt.kt b/briar-desktop/src/main/kotlin/androidx/compose/material/TextFieldExt.kt
index f91ff68663..6c06bb0807 100644
--- a/briar-desktop/src/main/kotlin/androidx/compose/material/TextFieldExt.kt
+++ b/briar-desktop/src/main/kotlin/androidx/compose/material/TextFieldExt.kt
@@ -116,27 +116,30 @@ fun TextField(
     )
 }
 
-val KeyEvent.isModifierKeyPressed: Boolean
+private val KeyEvent.isModifierKeyPressed: Boolean
     get() = isShiftPressed || isAltPressed || isMetaPressed || isCtrlPressed
 
-fun TextFieldValue.insertOrReplaceBy(replacement: CharSequence) = this.copy(
+private fun TextFieldValue.insertOrReplaceBy(replacement: CharSequence) = this.copy(
     text = text.replaceRange(selection.min, selection.max, replacement),
     selection = TextRange(selection.min + 1, selection.min + 1)
 )
 
-// tab navigation for multi-line TextField is broken upstream
-// see https://github.com/JetBrains/compose-jb/issues/109
-@OptIn(ExperimentalComposeUiApi::class)
-@Composable
-fun Modifier.moveFocusOnTab(
-    focusManager: FocusManager = LocalFocusManager.current
-) = onPreviewKeyEvent {
-    if (it.type == KeyEventType.KeyDown && it.key == Key.Tab) {
-        focusManager.moveFocus(
-            if (it.isShiftPressed) FocusDirection.Previous
-            else FocusDirection.Next
-        )
-        return@onPreviewKeyEvent true
+object TextFieldExt {
+
+    // tab navigation for multi-line TextField is broken upstream
+    // see https://github.com/JetBrains/compose-jb/issues/109
+    @OptIn(ExperimentalComposeUiApi::class)
+    @Composable
+    fun Modifier.moveFocusOnTab(
+        focusManager: FocusManager = LocalFocusManager.current
+    ) = onPreviewKeyEvent {
+        if (it.type == KeyEventType.KeyDown && it.key == Key.Tab) {
+            focusManager.moveFocus(
+                if (it.isShiftPressed) FocusDirection.Previous
+                else FocusDirection.Next
+            )
+            return@onPreviewKeyEvent true
+        }
+        false
     }
-    false
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/SearchTextField.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/SearchTextField.kt
index 1d32ffc4ca..cbfa8141ef 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/SearchTextField.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/SearchTextField.kt
@@ -33,6 +33,7 @@ import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
 import org.briarproject.briar.desktop.ui.ColoredIconButton
+import org.briarproject.briar.desktop.utils.AccessibilityUtils.description
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 
 @Composable
@@ -48,7 +49,7 @@ fun SearchTextField(searchValue: String, onValueChange: (String) -> Unit, onCont
         shape = RoundedCornerShape(0.dp),
         leadingIcon = {
             val padding = Modifier.padding(top = 8.dp, bottom = 8.dp, start = 32.dp, end = 4.dp)
-            Icon(Icons.Filled.Search, i18n("access.contacts.search"), padding)
+            Icon(Icons.Filled.Search, null, padding)
         },
         trailingIcon = {
             ColoredIconButton(
@@ -59,6 +60,6 @@ fun SearchTextField(searchValue: String, onValueChange: (String) -> Unit, onCont
                 modifier = Modifier.padding(end = 8.dp)
             )
         },
-        modifier = Modifier.fillMaxSize()
+        modifier = Modifier.fillMaxSize().description(i18n("access.contacts.filter"))
     )
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt
index aab5f86a7d..e020c1822d 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt
@@ -96,6 +96,7 @@ import org.briarproject.briar.desktop.theme.Orange500
 import org.briarproject.briar.desktop.theme.Red500
 import org.briarproject.briar.desktop.theme.surfaceVariant
 import org.briarproject.briar.desktop.ui.Constants.DIALOG_WIDTH
+import org.briarproject.briar.desktop.utils.AccessibilityUtils.description
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nF
 import org.briarproject.briar.desktop.utils.PreviewUtils
@@ -323,7 +324,7 @@ fun OwnLink(
                         duration = SnackbarDuration.Short,
                     )
                 }
-            }
+            }.description(i18n("contact.add.remote.your_link_hint"))
     ) {
         Text(
             handshakeLink,
@@ -384,7 +385,7 @@ fun ContactLink(
         remoteHandshakeLink,
         setRemoteHandshakeLink,
         label = { Text(i18n("contact.add.remote.contact_link_hint")) },
-        modifier = Modifier.fillMaxWidth(),
+        modifier = Modifier.fillMaxWidth().description(i18n("contact.add.remote.contact_link_hint")),
         keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
         singleLine = true,
         onEnter = { aliasFocusRequester.requestFocus() },
@@ -443,7 +444,8 @@ fun Alias(
         alias,
         setAddContactAlias,
         label = { Text(i18n("contact.add.remote.choose_nickname")) },
-        modifier = Modifier.fillMaxWidth().focusRequester(aliasFocusRequester),
+        modifier = Modifier.fillMaxWidth().focusRequester(aliasFocusRequester)
+            .description(i18n("contact.add.remote.choose_nickname")),
         keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
         singleLine = true,
         onEnter = onSubmitAddContactDialog,
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt
index 1787696cd4..6c11e914aa 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt
@@ -32,11 +32,11 @@ import androidx.compose.material.MaterialTheme
 import androidx.compose.material.Text
 import androidx.compose.material.TextField
 import androidx.compose.material.TextFieldDefaults
+import androidx.compose.material.TextFieldExt.moveFocusOnTab
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.Send
-import androidx.compose.material.moveFocusOnTab
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/login/LoginScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/login/LoginScreen.kt
index 3085a284c2..79cf12487c 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/login/LoginScreen.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/login/LoginScreen.kt
@@ -45,6 +45,7 @@ import org.briarproject.briar.desktop.login.LoginSubViewModel.State.MIGRATING
 import org.briarproject.briar.desktop.login.LoginSubViewModel.State.SIGNED_OUT
 import org.briarproject.briar.desktop.login.LoginSubViewModel.State.STARTED
 import org.briarproject.briar.desktop.login.LoginSubViewModel.State.STARTING
+import org.briarproject.briar.desktop.utils.AccessibilityUtils.description
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 
 @OptIn(ExperimentalMaterialApi::class)
@@ -118,7 +119,8 @@ fun LoginForm(
         isError = passwordInvalidError,
         errorMessage = i18n("startup.error.password_wrong"),
         keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next),
-        modifier = Modifier.fillMaxWidth().focusRequester(initialFocusRequester),
+        modifier = Modifier.fillMaxWidth().focusRequester(initialFocusRequester)
+            .description(i18n("startup.field.password")),
         onEnter = onEnter
     )
     TextButton(onClick = { passwordForgotten.value = true }) {
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/login/RegistrationScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/login/RegistrationScreen.kt
index a92db09023..271a398666 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/login/RegistrationScreen.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/login/RegistrationScreen.kt
@@ -44,6 +44,7 @@ import org.briarproject.briar.desktop.login.RegistrationSubViewModel.State.CREAT
 import org.briarproject.briar.desktop.login.RegistrationSubViewModel.State.CREATING
 import org.briarproject.briar.desktop.login.RegistrationSubViewModel.State.INSERT_NICKNAME
 import org.briarproject.briar.desktop.login.RegistrationSubViewModel.State.INSERT_PASSWORD
+import org.briarproject.briar.desktop.utils.AccessibilityUtils.description
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 
 @Composable
@@ -121,7 +122,8 @@ fun NicknameForm(
         isError = nicknameTooLongError,
         errorMessage = i18n("startup.error.name_too_long"),
         keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
-        modifier = Modifier.fillMaxWidth().focusRequester(initialFocusRequester),
+        modifier = Modifier.fillMaxWidth().focusRequester(initialFocusRequester)
+            .description(i18n("startup.field.nickname")),
         onEnter = onSubmit
     )
 
@@ -168,7 +170,7 @@ fun PasswordForm(
         keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next),
         modifier = Modifier.fillMaxWidth().run {
             if (focusRequester != null) focusRequester(focusRequester) else this
-        },
+        }.description(i18n(keyLabelPassword)),
         onEnter = { focusManager.moveFocus(FocusDirection.Next) },
     )
     OutlinedPasswordTextField(
@@ -180,7 +182,7 @@ fun PasswordForm(
         showErrorWhen = AFTER_FIRST_FOCUSSED,
         errorMessage = i18n("startup.error.passwords_not_match"),
         keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
-        modifier = Modifier.fillMaxWidth(),
+        modifier = Modifier.fillMaxWidth().description(i18n(keyLabelPasswordConfirmation)),
         onEnter = onSubmit,
     )
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/ChangePasswordDialog.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/ChangePasswordDialog.kt
index afe98b3c5d..e972bf9588 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/ChangePasswordDialog.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/settings/ChangePasswordDialog.kt
@@ -46,6 +46,7 @@ import androidx.compose.ui.text.input.KeyboardType
 import org.briarproject.bramble.api.crypto.DecryptionResult
 import org.briarproject.bramble.api.crypto.DecryptionResult.INVALID_PASSWORD
 import org.briarproject.briar.desktop.login.PasswordForm
+import org.briarproject.briar.desktop.utils.AccessibilityUtils.description
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 import org.briarproject.briar.desktop.utils.PreviewUtils.preview
 import java.lang.Float.min
@@ -216,7 +217,8 @@ fun PasswordForm(
             showErrorWhen = InitialFocusState.FROM_START,
             errorMessage = i18n("startup.error.password_wrong"),
             keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next),
-            modifier = Modifier.fillMaxWidth().focusRequester(initialFocusRequester),
+            modifier = Modifier.fillMaxWidth().focusRequester(initialFocusRequester)
+                .description(i18n("settings.security.password.current")),
             onEnter = { focusManager.moveFocus(FocusDirection.Next) },
         )
         PasswordForm(
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/utils/AccessibilityUtils.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/utils/AccessibilityUtils.kt
new file mode 100644
index 0000000000..33d638451c
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/utils/AccessibilityUtils.kt
@@ -0,0 +1,33 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2021-2022 The Briar Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package org.briarproject.briar.desktop.utils
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+
+object AccessibilityUtils {
+    /**
+     * Add a content description for accessibility services.
+     */
+    // Currently, mainly used as a work-around for poor Compose accessibility support,
+    // see, e.g., https://github.com/JetBrains/compose-jb/issues/2136
+    fun Modifier.description(description: String) =
+        semantics { contentDescription = description }
+}
diff --git a/briar-desktop/src/main/resources/strings/BriarDesktop.properties b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
index d4f2c7da2a..0586f6f815 100644
--- a/briar-desktop/src/main/resources/strings/BriarDesktop.properties
+++ b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
@@ -5,7 +5,7 @@ access.contacts.add=Add contact
 access.contact.menu=Show contact menu
 access.contacts.dropdown.connections.expand=Expand connection menu
 access.contacts.dropdown.contacts.expand=Expand contact menu
-access.contacts.search=Icon for searching contacts
+access.contacts.filter=Filter existing contacts by name or alias and add new contacts
 access.contacts.pending.remove=Remove pending contact
 access.introduction.back.contact=Go back to contact screen of introduction process
 access.introduction.close=Close introduction screen
@@ -95,6 +95,7 @@ introduction.response.declined.received_by_introducee={0} says that {1} declined
 contact.add.title_dialog=Add Contact
 contact.add.remote.title=Add Contact at a Distance
 contact.add.remote.your_link=Give this link to the contact you want to add
+contact.add.remote.your_link_hint=Own link
 contact.add.remote.copy_tooltip=Copy
 contact.add.remote.contact_link=Enter the link from your contact here
 contact.add.remote.contact_link_hint=Contact\'s link
-- 
GitLab