diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/VideoPlaybackControls.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/VideoPlaybackControls.kt index 9f136813884..02fa76d1a39 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/VideoPlaybackControls.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/VideoPlaybackControls.kt @@ -56,6 +56,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.coerceIn import androidx.compose.ui.unit.dp @@ -63,6 +65,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.audio.rememberSpokenDurationFormatter import io.getstream.chat.android.compose.ui.components.button.StreamButton import io.getstream.chat.android.compose.ui.components.button.StreamButtonSize import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyleDefaults @@ -115,7 +118,11 @@ internal fun VideoPlaybackControls( ) } + val durationDescription = rememberSpokenDurationFormatter()?.format(state.currentPosition.toInt()) Text( + modifier = Modifier.semantics { + if (durationDescription != null) contentDescription = durationDescription + }, text = ChatTheme.durationFormatter.format(state.currentPosition.toInt()), style = ChatTheme.typography.captionDefault, color = if (state.isPlaying) ChatTheme.colors.accentPrimary else ChatTheme.colors.textPrimary, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/PlaybackTimer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/PlaybackTimer.kt index 6bdca5bc4f0..99253987d88 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/PlaybackTimer.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/PlaybackTimer.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme @@ -40,7 +42,11 @@ internal fun PlaybackTimerText( val playbackInMs = (progress * totalDurationInMs).toInt() val timeToShow = if (countdown) totalDurationInMs - playbackInMs else playbackInMs val playbackText = ChatTheme.durationFormatter.format(timeToShow) + val spokenDuration = rememberSpokenDurationFormatter()?.format(timeToShow) Text( + modifier = Modifier.semantics { + if (spokenDuration != null) contentDescription = spokenDuration + }, text = playbackText, style = ChatTheme.typography.metadataEmphasis.copy(color), ) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/SpokenDurationFormatter.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/SpokenDurationFormatter.kt new file mode 100644 index 00000000000..af8e10797df --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/SpokenDurationFormatter.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.components.audio + +import android.icu.text.MeasureFormat +import android.icu.util.Measure +import android.icu.util.MeasureUnit +import android.os.Build +import android.view.accessibility.AccessibilityManager +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.getSystemService +import androidx.core.os.ConfigurationCompat +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.ui.common.helper.DurationFormatter +import java.util.Locale + +/** + * Builds spoken-form duration strings (e.g. "5 seconds", "1 minute 23 seconds") for use as + * `contentDescription` so TalkBack reads `0:05` as natural language instead of character by + * character. + * + * On API 24+ uses Android's ICU `MeasureFormat` for locale-aware natural language and caches + * the formatter instance for the lifetime of this object. On older API levels falls back to + * [fallbackFormatter] — the same clock format shown on screen. + * + * @param locale Locale used for natural-language output on API 24+. + * @param fallbackFormatter Formatter used to render the duration on API levels below 24. + */ +internal class SpokenDurationFormatter( + locale: Locale, + fallbackFormatter: DurationFormatter, +) { + private val formatStrategy: (Int) -> String = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + icuFormatStrategy(locale) + } else { + fallbackFormatter::format + } + + fun format(durationInMs: Int): String = formatStrategy(durationInMs) +} + +@RequiresApi(Build.VERSION_CODES.N) +private fun icuFormatStrategy(locale: Locale): (Int) -> String { + val formatter = MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.WIDE) + return { durationInMs -> + val totalSeconds = (durationInMs / MillisPerSecond).coerceAtLeast(0L) + val minutes = totalSeconds / SecondsPerMinute + val seconds = totalSeconds % SecondsPerMinute + when { + minutes > 0 && seconds > 0 -> formatter.formatMeasures( + Measure(minutes, MeasureUnit.MINUTE), + Measure(seconds, MeasureUnit.SECOND), + ) + minutes > 0 -> formatter.format(Measure(minutes, MeasureUnit.MINUTE)) + else -> formatter.format(Measure(seconds, MeasureUnit.SECOND)) + } + } +} + +/** + * Returns a [SpokenDurationFormatter] for the current configuration, or `null` when no + * accessibility service is enabled — call sites can skip computing the spoken description + * entirely in that case. + * + * The formatter instance is `remember`ed so the underlying `MeasureFormat` is created once + * per locale change, instead of on every recomposition. + */ +@Composable +internal fun rememberSpokenDurationFormatter(): SpokenDurationFormatter? { + if (!rememberIsAccessibilityEnabled()) return null + val locale = ConfigurationCompat.getLocales(LocalConfiguration.current)[0] ?: Locale.getDefault() + val fallbackFormatter = ChatTheme.durationFormatter + return remember(locale, fallbackFormatter) { + SpokenDurationFormatter(locale, fallbackFormatter) + } +} + +@Composable +private fun rememberIsAccessibilityEnabled(): Boolean { + val context = LocalContext.current + val manager = remember(context) { context.getSystemService() } ?: return false + var enabled by remember(manager) { mutableStateOf(manager.isEnabled) } + DisposableEffect(manager) { + val listener = AccessibilityManager.AccessibilityStateChangeListener { enabled = it } + manager.addAccessibilityStateChangeListener(listener) + enabled = manager.isEnabled + onDispose { manager.removeAccessibilityStateChangeListener(listener) } + } + return enabled +} + +private const val MillisPerSecond = 1000L +private const val SecondsPerMinute = 60L diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/common/MediaBadges.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/common/MediaBadges.kt index 9424260f69f..6b008dcd1a9 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/common/MediaBadges.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/common/MediaBadges.kt @@ -29,10 +29,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.audio.rememberSpokenDurationFormatter import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.StreamTokens import java.util.Locale @@ -92,7 +95,12 @@ private fun MediaBadge( contentDescription = null, tint = ChatTheme.colors.textOnInverse, ) + val durationDescription = rememberSpokenDurationFormatter() + ?.format(durationInSeconds.seconds.inWholeMilliseconds.toInt()) Text( + modifier = Modifier.semantics { + if (durationDescription != null) contentDescription = durationDescription + }, text = if (compact) { durationInSeconds.toCompactDuration() } else { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessage.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessage.kt index 8f86460eeba..16abcda92cf 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessage.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessage.kt @@ -189,10 +189,12 @@ private fun QuotedMessageText(body: QuotedMessageBody, color: Color) { ) } + val spokenText = body.spokenText Text( modifier = Modifier .weight(1f) - .testTag("Stream_QuotedMessage"), + .testTag("Stream_QuotedMessage") + .semantics { if (spokenText != null) contentDescription = spokenText }, text = body.text, style = ChatTheme.typography.metadataDefault, color = color, @@ -241,6 +243,7 @@ private fun QuotedMessageAttachmentPreview(body: QuotedMessageBody) { internal data class QuotedMessageBody( val text: String, + val spokenText: String? = null, @param:DrawableRes val iconId: Int? = null, val imagePreviewData: Any? = null, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt index b0b0729e61f..2628457dc21 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt @@ -19,11 +19,14 @@ package io.getstream.chat.android.compose.ui.components.messages import android.content.res.Resources import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.core.os.ConfigurationCompat import io.getstream.chat.android.client.extensions.durationInMs import io.getstream.chat.android.client.utils.attachment.isGiphy import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.audio.SpokenDurationFormatter import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.MimeTypeIconProvider import io.getstream.chat.android.compose.ui.util.getMessageTextResId @@ -37,6 +40,7 @@ import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageRe import io.getstream.chat.android.ui.common.utils.extensions.giphyFallbackPreviewUrl import io.getstream.chat.android.ui.common.utils.extensions.hasLink import io.getstream.chat.android.ui.common.utils.extensions.linkPreviewImageUrl +import java.util.Locale import io.getstream.chat.android.ui.common.R as UiCommonR internal class QuotedMessageBodyBuilder( @@ -44,6 +48,7 @@ internal class QuotedMessageBodyBuilder( private val autoTranslationEnabled: Boolean, private val durationFormatter: DurationFormatter, private val streamCdnImageResizing: StreamCdnImageResizing, + private val spokenDurationFormatter: SpokenDurationFormatter, ) { fun build(message: Message, currentUser: User?): QuotedMessageBody { val messageText = when { @@ -147,8 +152,10 @@ internal class QuotedMessageBodyBuilder( } summary.audioRecordingAttachment != null && size == 1 -> { + val durationMs = summary.audioRecordingAttachment.durationInMs ?: 0 QuotedMessageBody( - text = messageText.ifBlank { textForAudioRecording(summary.audioRecordingAttachment) }, + text = messageText.ifBlank { textForAudioRecording(durationMs) }, + spokenText = if (messageText.isBlank()) spokenTextForAudioRecording(durationMs) else null, iconId = R.drawable.stream_design_ic_voice, ) } @@ -172,8 +179,13 @@ internal class QuotedMessageBodyBuilder( } } - private fun textForAudioRecording(audioRecordingAttachment: Attachment): String { - val duration = durationFormatter.format(audioRecordingAttachment.durationInMs ?: 0) + private fun textForAudioRecording(durationMs: Int): String { + val duration = durationFormatter.format(durationMs) + return resources.getString(R.string.stream_compose_quoted_message_audio_recording, duration) + } + + private fun spokenTextForAudioRecording(durationMs: Int): String { + val duration = spokenDurationFormatter.format(durationMs) return resources.getString(R.string.stream_compose_quoted_message_audio_recording, duration) } @@ -258,13 +270,24 @@ internal fun rememberBodyBuilder(): QuotedMessageBodyBuilder { val autoTranslationEnabled = ChatTheme.config.translation.enabled val durationFormatter = ChatTheme.durationFormatter val streamCdnImageResizing: StreamCdnImageResizing = ChatTheme.streamCdnImageResizing + val locale = ConfigurationCompat.getLocales(LocalConfiguration.current)[0] ?: Locale.getDefault() + val spokenDurationFormatter = remember(locale, durationFormatter) { + SpokenDurationFormatter(locale, durationFormatter) + } - return remember(resources, autoTranslationEnabled, durationFormatter, streamCdnImageResizing) { + return remember( + resources, + autoTranslationEnabled, + durationFormatter, + streamCdnImageResizing, + spokenDurationFormatter, + ) { QuotedMessageBodyBuilder( resources = resources, autoTranslationEnabled = autoTranslationEnabled, durationFormatter = durationFormatter, streamCdnImageResizing = streamCdnImageResizing, + spokenDurationFormatter = spokenDurationFormatter, ) } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingContent.kt index 0e253fa30fd..07a7b357b07 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/AudioRecordingContent.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.components.audio.StaticWaveformSlider +import io.getstream.chat.android.compose.ui.components.audio.rememberSpokenDurationFormatter import io.getstream.chat.android.compose.ui.components.button.StreamButton import io.getstream.chat.android.compose.ui.components.button.StreamButtonSize import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyleDefaults @@ -156,7 +157,11 @@ internal fun MessageComposerAudioRecordingHoldContent( ) } + val durationDescription = rememberSpokenDurationFormatter()?.format(state.durationInMs) Text( + modifier = Modifier.semantics { + if (durationDescription != null) contentDescription = durationDescription + }, text = ChatTheme.durationFormatter.format(state.durationInMs), style = ChatTheme.typography.bodyEmphasis, color = ChatTheme.colors.textPrimary, @@ -202,7 +207,11 @@ internal fun MessageComposerAudioRecordingLockedContent( ) } + val durationDescription = rememberSpokenDurationFormatter()?.format(state.durationInMs) Text( + modifier = Modifier.semantics { + if (durationDescription != null) contentDescription = durationDescription + }, text = ChatTheme.durationFormatter.format(state.durationInMs), style = ChatTheme.typography.bodyEmphasis, color = ChatTheme.colors.textPrimary, @@ -309,7 +318,11 @@ private fun RowScope.OverviewPlaybackRow( verticalAlignment = Alignment.CenterVertically, ) { val playbackInMs = (currentProgress * state.durationInMs).toInt() + val durationDescription = rememberSpokenDurationFormatter()?.format(playbackInMs) Text( + modifier = Modifier.semantics { + if (durationDescription != null) contentDescription = durationDescription + }, text = ChatTheme.durationFormatter.format(playbackInMs), style = ChatTheme.typography.bodyEmphasis, color = ChatTheme.colors.textPrimary, diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilderTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilderTest.kt index 85945e4eb10..4063a5ad391 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilderTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilderTest.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.ui.components.messages import android.content.res.Resources import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.components.attachments.files.FileIconData +import io.getstream.chat.android.compose.ui.components.audio.SpokenDurationFormatter import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.models.Message @@ -39,6 +40,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.util.Date +import java.util.Locale import io.getstream.chat.android.ui.common.R as UiCommonR internal class QuotedMessageBodyBuilderTest { @@ -53,11 +55,13 @@ internal class QuotedMessageBodyBuilderTest { expected: QuotedMessageBody, ) { val resources = mockResources() + val durationFormatter = mockDurationFormatter() val builder = QuotedMessageBodyBuilder( resources = resources, autoTranslationEnabled = autoTranslationEnabled, - durationFormatter = mockDurationFormatter(), + durationFormatter = durationFormatter, streamCdnImageResizing = defaultStreamCdnImageResizing(), + spokenDurationFormatter = SpokenDurationFormatter(Locale.US, durationFormatter), ) builder.build(message, currentUser) `should be equal to` expected @@ -452,6 +456,7 @@ internal class QuotedMessageBodyBuilderTest { false, QuotedMessageBody( text = MOCK_AUDIO_RECORDING, + spokenText = MOCK_AUDIO_RECORDING, iconId = R.drawable.stream_design_ic_voice, ), ),