[a11y] Make more items focusable (#4605)

* Fix settings entry point not available when there is no avatar on the account. Fixes #4599.

* Use Ktx extension `String.toUri()`

* Allow screen reader to focus on the user avatar to allow editing it.

* Fix import order
This commit is contained in:
Benoit Marty
2025-04-22 08:50:50 +02:00
committed by GitHub
parent ec88cbe0e5
commit e2e58749f5
15 changed files with 48 additions and 24 deletions

View File

@@ -8,6 +8,7 @@
package io.element.android.features.call.impl.utils
import android.net.Uri
import androidx.core.net.toUri
import javax.inject.Inject
class CallIntentDataParser @Inject constructor() {
@@ -17,7 +18,7 @@ class CallIntentDataParser @Inject constructor() {
)
fun parse(data: String?): String? {
val parsedUrl = data?.let { Uri.parse(data) } ?: return null
val parsedUrl = data?.toUri() ?: return null
val scheme = parsedUrl.scheme
return when {
scheme in validHttpSchemes -> parsedUrl
@@ -37,7 +38,7 @@ class CallIntentDataParser @Inject constructor() {
private fun Uri.getUrlParameter(): Uri? {
return getQueryParameter("url")
?.let { urlParameter ->
Uri.parse(urlParameter).takeIf { uri ->
urlParameter.toUri().takeIf { uri ->
uri.scheme in validHttpSchemes && !uri.host.isNullOrBlank()
}
}

View File

@@ -9,11 +9,11 @@ package io.element.android.features.login.impl.util
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.libraries.core.data.tryOrNull
fun openLearnMorePage(context: Context) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(AuthenticationConfig.SLIDING_SYNC_READ_MORE_URL))
val intent = Intent(Intent.ACTION_VIEW, AuthenticationConfig.SLIDING_SYNC_READ_MORE_URL.toUri())
tryOrNull { context.startActivity(intent) }
}

View File

@@ -7,7 +7,7 @@
package io.element.android.features.login.impl.web
import android.net.Uri
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.resolver.network.WellknownAPI
@@ -43,7 +43,7 @@ class DefaultWebClientUrlForAuthenticationRetriever @Inject constructor(
}
val registrationHelperUrl = result.registrationHelperUrl
return if (registrationHelperUrl != null) {
Uri.parse(registrationHelperUrl)
registrationHelperUrl.toUri()
.buildUpon()
.appendQueryParameter("hs_url", homeServerUrl)
.build()

View File

@@ -58,7 +58,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
@Composable
override fun present(): EditUserProfileState {
val cameraPermissionState = cameraPermissionPresenter.present()
var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) }
var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.toUri()) }
var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri ->

View File

@@ -9,6 +9,7 @@ package io.element.android.features.roomdetails.impl.edit
import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.media.AvatarAction
@@ -22,7 +23,7 @@ open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEd
aRoomDetailsEditState(),
aRoomDetailsEditState(roomTopic = ""),
aRoomDetailsEditState(roomRawName = ""),
aRoomDetailsEditState(roomAvatarUrl = Uri.parse("example://uri")),
aRoomDetailsEditState(roomAvatarUrl = "example://uri".toUri()),
aRoomDetailsEditState(canChangeName = true, canChangeTopic = false, canChangeAvatar = true, saveButtonEnabled = false),
aRoomDetailsEditState(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false),
aRoomDetailsEditState(saveAction = AsyncAction.Loading),

View File

@@ -9,12 +9,12 @@ package io.element.android.libraries.androidutils.browser
import android.app.Activity
import android.content.ActivityNotFoundException
import android.net.Uri
import android.os.Bundle
import android.provider.Browser
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsSession
import androidx.core.net.toUri
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import java.util.Locale
@@ -58,7 +58,7 @@ fun Activity.openUrlInChromeCustomTab(
putString("Accept-Language", Locale.getDefault().toLanguageTag())
})
}
.launchUrl(this, Uri.parse(url))
.launchUrl(this, url.toUri())
} catch (activityNotFoundException: ActivityNotFoundException) {
openUrlInExternalApp(url)
}

View File

@@ -19,6 +19,7 @@ import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.RequiresApi
import androidx.core.content.pm.PackageInfoCompat
import androidx.core.net.toUri
import io.element.android.libraries.androidutils.R
import io.element.android.libraries.androidutils.compat.getApplicationInfoCompat
import io.element.android.libraries.core.mimetype.MimeTypes
@@ -121,7 +122,7 @@ fun Context.startInstallFromSourceIntent(
noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
.setData(Uri.parse("package:$packageName"))
.setData("package:$packageName".toUri())
try {
activityResultLauncher.launch(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
@@ -165,7 +166,7 @@ fun Context.openUrlInExternalApp(
url: String,
errorMessage: String = getString(R.string.error_no_compatible_app_found),
) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
if (this !is Activity) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}

View File

@@ -8,7 +8,8 @@
package io.element.android.libraries.androidutils.uri
import android.net.Uri
import androidx.core.net.toUri
const val IGNORED_SCHEMA = "ignored"
fun createIgnoredUri(path: String): Uri = Uri.parse("$IGNORED_SCHEMA://$path")
fun createIgnoredUri(path: String): Uri = "$IGNORED_SCHEMA://$path".toUri()

View File

@@ -23,6 +23,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
@@ -59,6 +60,7 @@ fun Avatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
modifier = commonModifier,
contentDescription = contentDescription,
)
} else {
ImageAvatar(
@@ -103,11 +105,13 @@ private fun ImageAvatar(
InitialsAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
else -> InitialsAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
}
@@ -118,6 +122,7 @@ private fun ImageAvatar(
private fun InitialsAvatar(
avatarData: AvatarData,
forcedAvatarSize: Dp?,
contentDescription: String?,
modifier: Modifier = Modifier,
) {
val avatarColors = AvatarColorsProvider.provide(avatarData.id)
@@ -130,7 +135,11 @@ private fun InitialsAvatar(
val lineHeight = originalFont.lineHeight * ratio
Text(
modifier = Modifier
.clearAndSetSemantics {}
.clearAndSetSemantics {
contentDescription?.let {
this.contentDescription = it
}
}
.align(Alignment.Center),
text = avatarData.initial,
style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp),

View File

@@ -9,7 +9,6 @@ package io.element.android.libraries.fullscreenintent.impl
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.compose.runtime.Composable
@@ -17,6 +16,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import io.element.android.libraries.architecture.Presenter
@@ -77,7 +77,7 @@ class FullScreenIntentPermissionsPresenter @Inject constructor(
try {
val intent = Intent(
Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
Uri.parse("package:${buildMeta.applicationId}")
"package:${buildMeta.applicationId}".toUri()
)
externalIntentLauncher.launch(intent)
} catch (e: ActivityNotFoundException) {

View File

@@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.impl.permalink
import android.net.Uri
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.MatrixConfiguration
import io.element.android.libraries.core.extensions.replacePrefix
@@ -43,7 +44,7 @@ class DefaultMatrixToConverter @Inject constructor() : MatrixToConverter {
// Web or client url
SUPPORTED_PATHS.any { it in uriString } -> {
val path = SUPPORTED_PATHS.first { it in uriString }
Uri.parse(baseUrl + uriString.substringAfter(path))
(baseUrl + uriString.substringAfter(path)).toUri()
}
// URL is not supported
else -> null

View File

@@ -7,7 +7,7 @@
package io.element.android.libraries.matrix.impl.permalink
import android.net.Uri
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.EventId
@@ -38,7 +38,7 @@ class DefaultPermalinkParser @Inject constructor(
* https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md
*/
override fun parse(uriString: String): PermalinkData {
val uri = Uri.parse(uriString)
val uri = uriString.toUri()
// the client or element-based domain permalinks (e.g. https://app.element.io/#/user/@chagai95:matrix.org) don't have the
// mxid in the first param (like matrix.to does - https://matrix.to/#/@chagai95:matrix.org) but rather in the second after /user/ so /user/mxid
// so convert URI to matrix.to to simplify parsing process

View File

@@ -23,9 +23,13 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -36,6 +40,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun EditableAvatarView(
@@ -50,6 +55,7 @@ fun EditableAvatarView(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val a11yAvatar = stringResource(CommonStrings.a11y_avatar)
Box(
modifier = Modifier
.size(avatarSize.dp)
@@ -59,6 +65,9 @@ fun EditableAvatarView(
indication = ripple(bounded = false),
)
.testTag(TestTags.editAvatar)
.clearAndSetSemantics {
contentDescription = a11yAvatar
},
) {
when (avatarUrl?.scheme) {
null, "mxc" -> {
@@ -112,7 +121,7 @@ open class EditableAvatarViewUriProvider : PreviewParameterProvider<Uri?> {
override val values: Sequence<Uri?>
get() = sequenceOf(
null,
Uri.parse("mxc://matrix.org/123456"),
Uri.parse("https://example.com/avatar.jpg"),
"mxc://matrix.org/123456".toUri(),
"https://example.com/avatar.jpg".toUri(),
)
}

View File

@@ -10,10 +10,10 @@ package io.element.android.libraries.oidc.impl.customtab
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsServiceConnection
import androidx.browser.customtabs.CustomTabsSession
import androidx.core.net.toUri
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
@@ -55,7 +55,7 @@ class CustomTabHandler @Inject constructor(
customTabsSession = customTabsClient?.newSession(null)
}
customTabsSession?.mayLaunchUrl(Uri.parse(url), null, null)
customTabsSession?.mayLaunchUrl(url.toUri(), null, null)
}
fun disposeCustomTab() {

View File

@@ -7,6 +7,7 @@
package io.element.android.libraries.push.impl.notifications.model
import android.net.Uri
import androidx.core.net.toUri
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
@@ -52,7 +53,7 @@ data class NotifiableMessageEvent(
// Example of value:
// content://io.element.android.x.debug.notifications.fileprovider/downloads/temp/notif/matrix.org/XGItzSDOnSyXjYtOPfiKexDJ
val imageUri: Uri?
get() = imageUriString?.let { Uri.parse(it) }
get() = imageUriString?.toUri()
}
/**