Merge branch 'develop' into sync-localazy

This commit is contained in:
ganfra
2026-09-18 10:39:43 +02:00
committed by GitHub
214 changed files with 3639 additions and 707 deletions
+7 -1
View File
@@ -19,6 +19,12 @@ android {
buildFeatures {
buildConfig = true
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
dependencies {
@@ -36,5 +42,5 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
testCommonDependencies(libs)
testCommonDependencies(libs, true)
}
@@ -47,6 +47,7 @@ fun TextFieldDialog(
maxLines: Int = minLines,
content: String? = null,
label: String? = null,
supportingText: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
submitText: String = stringResource(CommonStrings.action_ok),
destructiveSubmit: Boolean = false,
@@ -84,6 +85,7 @@ fun TextFieldDialog(
TextFieldListItem(
placeholder = placeholder.orEmpty(),
label = label,
supportingText = supportingText,
text = textFieldContents,
onTextChange = {
error = if (!validation(it.text)) onValidationErrorMessage else null
@@ -57,6 +57,7 @@ fun TextFieldListItem(
minLines: Int = 1,
maxLines: Int = minLines,
label: String? = null,
supportingText: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
@@ -66,7 +67,7 @@ fun TextFieldListItem(
placeholder = placeholder,
label = label,
validity = if (error != null) TextFieldValidity.Invalid else TextFieldValidity.None,
supportingText = error,
supportingText = error ?: supportingText,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
maxLines = maxLines,
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.media
import android.view.MotionEvent
/**
* Interprets a waveform pointer event and reports whether it was consumed.
*
* @param action [MotionEvent] action, such as [MotionEvent.ACTION_DOWN].
* @param x Horizontal position of the pointer, in pixels, relative to the waveform canvas.
* @param waveformWidthPx Width of the drawn waveform, in pixels. Touches past this are ignored.
* @param currentSeekProgress In-progress drag progress (`0` to `1`), or `null` if no drag is active.
* @param onDisallowParentIntercept Called with `true` when a drag starts, and `false` when it ends,
* so a parent scroller does not steal the gesture.
* @param onUpdateSeekProgress Called with the current drag progress, or `null` when the drag ends.
* @param onCommitSeek Called with the final progress when the user lifts their finger after a drag.
* @return `true` when the event was consumed by the waveform.
*/
internal fun handleWaveformPointerEvent(
action: Int,
x: Float,
waveformWidthPx: Float,
currentSeekProgress: Float?,
onDisallowParentIntercept: (Boolean) -> Unit,
onUpdateSeekProgress: (Float?) -> Unit,
onCommitSeek: (Float) -> Unit,
): Boolean {
return when (action) {
MotionEvent.ACTION_DOWN -> {
val progress = seekProgressFromPointerX(x, waveformWidthPx)
if (progress != null) {
onDisallowParentIntercept(true)
onUpdateSeekProgress(progress)
true
} else {
false
}
}
MotionEvent.ACTION_MOVE -> {
val progress = seekProgressFromPointerX(x, waveformWidthPx)
if (progress != null) {
onUpdateSeekProgress(progress)
}
true
}
MotionEvent.ACTION_UP -> {
onDisallowParentIntercept(false)
currentSeekProgress?.let(onCommitSeek)
onUpdateSeekProgress(null)
true
}
MotionEvent.ACTION_CANCEL -> {
onDisallowParentIntercept(false)
onUpdateSeekProgress(null)
true
}
else -> false
}
}
internal fun seekProgressFromPointerX(x: Float, waveformWidthPx: Float): Float? {
if (x in 0F..waveformWidthPx) {
return x / waveformWidthPx
}
return null
}
@@ -0,0 +1,159 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.media
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* Maximum gap, in wall-clock milliseconds, between two progress values that we still treat as
* "the same place" while audio is playing.
*
* Player progress is sampled on a timer, while the waveform cursor is animated every frame.
* A gap smaller than this is expected sampling lag and should be ignored. A larger gap means a
* real jump (seek acknowledged, pause, or the animation has drifted) and the cursor should snap.
*
* The comparison uses wall-clock time, so faster playback makes the same progress-fraction
* delta count as a smaller time gap.
*/
internal const val PLAYING_SYNC_THRESHOLD_MS = 200L
/**
* When media duration is unknown (`durationMs <= 0`), progress is compared as a 0–1 fraction
* instead of milliseconds. Values this close are treated as equal.
*/
private const val ZERO_DURATION_PROGRESS_EPSILON = 0.01f
/**
* Whether a player progress sample should update the cursor, or be ignored because a user seek
* has not been acknowledged yet.
*
* After a seek, the player can keep emitting samples from the old position. Those are dropped
* until a sample is close enough to [pendingSeek].
*
* @param playbackProgress Latest player progress, from 0 to 1.
* @param pendingSeek Target progress of an in-flight user seek, or `null` if none.
* @param durationMs Total media duration in milliseconds. Used to convert a progress delta into time.
* @param playbackSpeed Current playback rate (`1` = realtime). Used when converting the delta to wall-clock time.
* @param syncThresholdMs How close the sample must be to [pendingSeek], in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS].
*/
internal fun shouldApplyPlayerProgress(
playbackProgress: Float,
pendingSeek: Float?,
durationMs: Long,
playbackSpeed: Float,
syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS,
): Boolean {
if (pendingSeek == null) return true
return hasCaughtUpToSeek(
playbackProgress = playbackProgress,
pendingSeek = pendingSeek,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
syncThresholdMs = syncThresholdMs,
)
}
/**
* Whether [playbackProgress] is close enough to [pendingSeek] that the player has likely applied
* the seek.
*
* @param playbackProgress Latest player progress, from 0 to 1.
* @param pendingSeek Target progress of the in-flight user seek, from 0 to 1.
* @param durationMs Total media duration in milliseconds. If `<= 0`, [ZERO_DURATION_PROGRESS_EPSILON] is used instead.
* @param playbackSpeed Current playback rate (`1` = realtime).
* @param syncThresholdMs Allowed wall-clock gap in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS].
*/
internal fun hasCaughtUpToSeek(
playbackProgress: Float,
pendingSeek: Float,
durationMs: Long,
playbackSpeed: Float,
syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS,
): Boolean {
val delta = abs(playbackProgress - pendingSeek)
if (durationMs <= 0L) {
return delta <= ZERO_DURATION_PROGRESS_EPSILON
}
val deltaMs = delta * durationMs / playbackSpeed.coerceAtLeast(0.01f)
return deltaMs <= syncThresholdMs
}
/** Player progress at or below this is treated as "at the start" when detecting a loop/restart. */
private const val RESTART_PROGRESS_EPSILON = 0.01f
/** Animated cursor at or above this is treated as "at the end" when detecting a loop/restart. */
private const val RESTART_ANIMATED_PROGRESS = 0.9f
/**
* Whether the animated cursor should jump to the latest player sample instead of continuing its
* linear animation.
*
* Returns `true` when:
* - playback is paused or duration is unknown (there is no animation to follow)
* - the player is ahead of the cursor by more than [syncThresholdMs]
* - playback restarted from the beginning (cursor near the end, player near the start)
*
* Returns `false` when the values already match, or when the player is only slightly ahead
* (or slightly behind) so the in-flight animation can catch up without a visible jump.
*
* @param playbackProgress Latest player progress, from 0 to 1.
* @param animatedProgress Current animated cursor progress, from 0 to 1.
* @param isPlaying Whether media is currently playing.
* @param durationMs Total media duration in milliseconds. Used to convert a progress delta into time.
* @param playbackSpeed Current playback rate (`1` = realtime). Used when converting the delta to wall-clock time.
* @param syncThresholdMs Drift that is still ignored while playing, in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS].
*/
internal fun shouldSnapToPlayer(
playbackProgress: Float,
animatedProgress: Float,
isPlaying: Boolean,
durationMs: Long,
playbackSpeed: Float,
syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS,
): Boolean {
val delta = abs(playbackProgress - animatedProgress)
if (delta == 0f) return false
if (!isPlaying || durationMs <= 0L) return true
val deltaMs = delta * durationMs / playbackSpeed.coerceAtLeast(0.01f)
if (playbackProgress < animatedProgress) {
return isPlaybackRestart(playbackProgress, animatedProgress)
}
return deltaMs > syncThresholdMs
}
/**
* Detects a loop: the player has wrapped to the start while the cursor is still near the end.
*/
private fun isPlaybackRestart(
playbackProgress: Float,
animatedProgress: Float,
): Boolean {
return playbackProgress <= RESTART_PROGRESS_EPSILON && animatedProgress >= RESTART_ANIMATED_PROGRESS
}
/**
* Remaining wall-clock milliseconds to tween the cursor from [progress] to the end of the media.
*
* Used to time the linear playback animation between infrequent player samples. A result of `0`
* means the cursor is already at (or past) the end and should snap rather than tween.
*
* @param progress Current cursor progress, from 0 to 1.
* @param durationMs Total media duration in milliseconds.
* @param playbackSpeed Current playback rate (`1` = realtime). Faster speeds shorten the result.
*/
internal fun remainingPlaybackAnimationMs(
progress: Float,
durationMs: Long,
playbackSpeed: Float,
): Int {
val remainingProgress = (1f - progress).coerceAtLeast(0f)
return (remainingProgress * durationMs / playbackSpeed.coerceAtLeast(0.01f))
.roundToInt()
.coerceAtLeast(0)
}
@@ -8,17 +8,24 @@
package io.element.android.libraries.designsystem.components.media
import android.view.MotionEvent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
@@ -41,6 +48,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
@@ -53,6 +61,9 @@ private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
* @param waveform The waveform to display.
* @param onSeek Callback when the user seeks the waveform. Called with a value between 0 and 1.
* @param modifier The modifier to be applied to the view.
* @param isPlaying Whether playback is currently in progress. Used to drive a linear progress animation.
* @param durationMs The total duration of the media in milliseconds. Used to time the linear animation.
* @param playbackSpeed The current playback speed. Used to time the linear animation.
* @param seekEnabled Whether the user can seek the waveform or not.
* @param brush The brush to use to draw the waveform.
* @param progressBrush The brush to use to draw the progress.
@@ -67,6 +78,9 @@ fun WaveformPlaybackView(
waveform: ImmutableList<Float>,
onSeek: (progress: Float) -> Unit,
modifier: Modifier = Modifier,
isPlaying: Boolean = false,
durationMs: Long = 0L,
playbackSpeed: Float = 1f,
seekEnabled: Boolean = true,
brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary),
progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary),
@@ -75,14 +89,36 @@ fun WaveformPlaybackView(
linePadding: Dp = 2.dp,
) {
val seekProgress = remember { mutableStateOf<Float?>(null) }
// Last user-seek target, kept until the player reports a nearby sample.
var pendingSeek by remember { mutableStateOf<Float?>(null) }
var canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) }
var canvasSizePx by remember { mutableStateOf(Size(0f, 0f)) }
val progress by remember(playbackProgress, seekProgress.value) {
derivedStateOf {
seekProgress.value ?: playbackProgress
}
}
val progressAnimated = animateFloatAsState(targetValue = progress, label = "progressAnimation")
val progressAnimated = remember { Animatable(playbackProgress) }
// Bumped after a snap or user seek so AnimateCursorWhilePlaying restarts its tween.
var seekGeneration by remember { mutableIntStateOf(0) }
val coroutineScope = rememberCoroutineScope()
ApplyPlayerProgressToCursor(
playbackProgress = playbackProgress,
isPlaying = isPlaying,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
pendingSeek = pendingSeek,
seekProgress = seekProgress,
progressAnimated = progressAnimated,
onAcknowledgePendingSeek = { pendingSeek = null },
onCursorSnap = { seekGeneration++ },
)
AnimateCursorWhilePlaying(
isPlaying = isPlaying,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
seekGeneration = seekGeneration,
progressAnimated = progressAnimated,
)
val progress = seekProgress.value ?: progressAnimated.value
val amplitudeDisplayCount by remember(canvasSize, lineWidth, linePadding) {
derivedStateOf {
(canvasSize.width.value / (lineWidth.value + linePadding.value)).toInt()
@@ -107,30 +143,22 @@ fun WaveformPlaybackView(
.let {
if (!seekEnabled) return@let it
it.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { e ->
return@pointerInteropFilter when (e.action) {
MotionEvent.ACTION_DOWN -> {
if (e.x in 0F..waveformWidthPx) {
requestDisallowInterceptTouchEvent.invoke(true)
seekProgress.value = e.x / waveformWidthPx
true
} else {
false
handleWaveformPointerEvent(
action = e.action,
x = e.x,
waveformWidthPx = waveformWidthPx,
currentSeekProgress = seekProgress.value,
onDisallowParentIntercept = { requestDisallowInterceptTouchEvent.invoke(it) },
onUpdateSeekProgress = { seekProgress.value = it },
onCommitSeek = { seek ->
pendingSeek = seek
onSeek(seek)
coroutineScope.launch {
progressAnimated.snapTo(seek)
seekGeneration++
}
}
MotionEvent.ACTION_MOVE -> {
if (e.x in 0F..waveformWidthPx) {
seekProgress.value = e.x / waveformWidthPx
}
true
}
MotionEvent.ACTION_UP -> {
requestDisallowInterceptTouchEvent.invoke(false)
seekProgress.value?.let(onSeek)
seekProgress.value = null
true
}
else -> false
}
},
)
}
}
.then(modifier)
@@ -149,7 +177,7 @@ fun WaveformPlaybackView(
drawRect(
brush = progressBrush,
size = Size(
width = progressAnimated.value * waveformWidthPx,
width = progress * waveformWidthPx,
height = canvasSizePx.height
),
blendMode = BlendMode.SrcAtop
@@ -158,7 +186,7 @@ fun WaveformPlaybackView(
drawRoundRect(
brush = cursorBrush,
topLeft = Offset(
x = progressAnimated.value * waveformWidthPx,
x = progress * waveformWidthPx,
y = 1f
),
size = Size(
@@ -172,6 +200,103 @@ fun WaveformPlaybackView(
}
}
/**
* Applies discrete player progress samples to the animated cursor.
*
* Samples that belong to a position before an in-flight user seek are ignored. Once a sample
* is close enough to [pendingSeek], that seek is considered acknowledged. The cursor then
* either snaps to the sample (pause, large jump, or playback restart) or keeps interpolating.
*
* @param playbackProgress Latest player progress, from 0 to 1.
* @param isPlaying Whether media is currently playing.
* @param durationMs Total media duration in milliseconds.
* @param playbackSpeed Current playback rate (`1` = realtime).
* @param pendingSeek Target progress of an in-flight user seek, or `null` if none.
* @param seekProgress Progress while the user is dragging the cursor; non-null means a drag is in progress.
* @param progressAnimated Animated cursor progress.
* @param onAcknowledgePendingSeek Called when a player sample has caught up to [pendingSeek].
* @param onCursorSnap Called after the cursor jumps, so the playing animation can restart from the new position.
*/
@Composable
private fun ApplyPlayerProgressToCursor(
playbackProgress: Float,
isPlaying: Boolean,
durationMs: Long,
playbackSpeed: Float,
pendingSeek: Float?,
seekProgress: State<Float?>,
progressAnimated: Animatable<Float, AnimationVector1D>,
onAcknowledgePendingSeek: () -> Unit,
onCursorSnap: () -> Unit,
) {
val latestOnAcknowledgePendingSeek by rememberUpdatedState(onAcknowledgePendingSeek)
val latestOnCursorSnap by rememberUpdatedState(onCursorSnap)
LaunchedEffect(playbackProgress, isPlaying, durationMs, playbackSpeed, pendingSeek) {
if (seekProgress.value != null) return@LaunchedEffect
if (!shouldApplyPlayerProgress(
playbackProgress = playbackProgress,
pendingSeek = pendingSeek,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
)
) {
return@LaunchedEffect
}
latestOnAcknowledgePendingSeek()
if (shouldSnapToPlayer(
playbackProgress = playbackProgress,
animatedProgress = progressAnimated.value,
isPlaying = isPlaying,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
)
) {
progressAnimated.snapTo(playbackProgress)
latestOnCursorSnap()
}
}
}
/**
* Animates the cursor linearly from its current position to the end of the media.
*
* Player samples are relatively infrequent, so between them we interpolate using the remaining
* duration and [playbackSpeed]. Changing [seekGeneration] (after a snap or user seek) cancels
* the current tween and starts a new one from the updated cursor position.
*
* @param isPlaying Whether media is currently playing. Animation only runs while this is true.
* @param durationMs Total media duration in milliseconds. Must be `> 0` to animate.
* @param playbackSpeed Current playback rate (`1` = realtime). Faster speeds shorten the tween.
* @param seekGeneration Counter incremented when the cursor jumps, used to restart this effect.
* @param progressAnimated Animated cursor progress, tweened toward `1`.
*/
@Composable
private fun AnimateCursorWhilePlaying(
isPlaying: Boolean,
durationMs: Long,
playbackSpeed: Float,
seekGeneration: Int,
progressAnimated: Animatable<Float, AnimationVector1D>,
) {
LaunchedEffect(isPlaying, durationMs, playbackSpeed, seekGeneration) {
if (isPlaying && durationMs > 0L) {
val remainingMs = remainingPlaybackAnimationMs(
progress = progressAnimated.value,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
)
if (remainingMs == 0) {
progressAnimated.snapTo(1f)
} else {
progressAnimated.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = remainingMs, easing = LinearEasing),
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun WaveformPlaybackViewPreview() = ElementPreview {
@@ -0,0 +1,134 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.media
import android.view.MotionEvent
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class WaveformPlaybackSeekTest {
@Test
fun `a down event inside the waveform starts a seek`() {
val result = handleEvent(action = MotionEvent.ACTION_DOWN, x = 40f)
assertThat(result.consumed).isTrue()
assertThat(result.disallowParentIntercept).isTrue()
assertThat(result.seekProgress).isEqualTo(0.4f)
assertThat(result.committedSeek).isNull()
}
@Test
fun `a down event outside the waveform is ignored`() {
val result = handleEvent(action = MotionEvent.ACTION_DOWN, x = 150f)
assertThat(result.consumed).isFalse()
assertThat(result.seekProgress).isNull()
assertThat(result.disallowParentIntercept).isNull()
}
@Test
fun `a move event inside the waveform updates the seek progress`() {
val result = handleEvent(action = MotionEvent.ACTION_MOVE, x = 80f)
assertThat(result.consumed).isTrue()
assertThat(result.seekProgress).isEqualTo(0.8f)
assertThat(result.disallowParentIntercept).isNull()
}
@Test
fun `a move event outside the waveform is still consumed without updating progress`() {
val result = handleEvent(action = MotionEvent.ACTION_MOVE, x = 150f)
assertThat(result.consumed).isTrue()
assertThat(result.seekProgress).isNull()
}
@Test
fun `an up event commits the in-progress seek`() {
val result = handleEvent(
action = MotionEvent.ACTION_UP,
currentSeekProgress = 0.6f,
)
assertThat(result.consumed).isTrue()
assertThat(result.disallowParentIntercept).isFalse()
assertThat(result.committedSeek).isEqualTo(0.6f)
assertThat(result.seekProgress).isNull()
assertThat(result.clearedSeekProgress).isTrue()
}
@Test
fun `an up event without an in-progress seek does not commit`() {
val result = handleEvent(action = MotionEvent.ACTION_UP, currentSeekProgress = null)
assertThat(result.consumed).isTrue()
assertThat(result.committedSeek).isNull()
assertThat(result.clearedSeekProgress).isTrue()
}
@Test
fun `a cancel event clears the in-progress seek without committing`() {
val result = handleEvent(
action = MotionEvent.ACTION_CANCEL,
currentSeekProgress = 0.6f,
)
assertThat(result.consumed).isTrue()
assertThat(result.disallowParentIntercept).isFalse()
assertThat(result.committedSeek).isNull()
assertThat(result.clearedSeekProgress).isTrue()
}
@Test
fun `unknown motion actions are ignored`() {
val result = handleEvent(action = MotionEvent.ACTION_POINTER_DOWN)
assertThat(result.consumed).isFalse()
assertThat(result.seekProgress).isNull()
assertThat(result.committedSeek).isNull()
}
@Test
fun `seek progress is null when the pointer is outside the waveform`() {
assertThat(seekProgressFromPointerX(x = -1f, waveformWidthPx = 100f)).isNull()
assertThat(seekProgressFromPointerX(x = 101f, waveformWidthPx = 100f)).isNull()
assertThat(seekProgressFromPointerX(x = 0f, waveformWidthPx = 100f)).isEqualTo(0f)
assertThat(seekProgressFromPointerX(x = 100f, waveformWidthPx = 100f)).isEqualTo(1f)
}
private fun handleEvent(
action: Int,
x: Float = 0f,
waveformWidthPx: Float = 100f,
currentSeekProgress: Float? = null,
): HandleResult {
var disallowParentIntercept: Boolean? = null
var seekProgress: Float? = currentSeekProgress
var clearedSeekProgress = false
var committedSeek: Float? = null
val consumed = handleWaveformPointerEvent(
action = action,
x = x,
waveformWidthPx = waveformWidthPx,
currentSeekProgress = currentSeekProgress,
onDisallowParentIntercept = { disallowParentIntercept = it },
onUpdateSeekProgress = {
seekProgress = it
if (it == null) clearedSeekProgress = true
},
onCommitSeek = { committedSeek = it },
)
return HandleResult(
consumed = consumed,
disallowParentIntercept = disallowParentIntercept,
seekProgress = seekProgress,
clearedSeekProgress = clearedSeekProgress,
committedSeek = committedSeek,
)
}
private data class HandleResult(
val consumed: Boolean,
val disallowParentIntercept: Boolean?,
val seekProgress: Float?,
val clearedSeekProgress: Boolean,
val committedSeek: Float?,
)
}
@@ -0,0 +1,280 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.media
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class WaveformPlaybackSyncTest {
@Test
fun `paused cursor always snaps to a different player sample`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.5f,
animatedProgress = 0.2f,
isPlaying = false,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `paused cursor does not snap when it already matches the player`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.5f,
animatedProgress = 0.5f,
isPlaying = false,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `playing cursor ignores drift under the sync threshold`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.21f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `playing cursor snaps when the player is ahead beyond the sync threshold`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.3f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `playing cursor does not snap back when the player lags behind the animation`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0f,
animatedProgress = 0.05f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `playing cursor snaps back when playback restarts from the beginning`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0f,
animatedProgress = 1f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `stale player samples are ignored until they catch up to a pending seek`() {
assertThat(
shouldApplyPlayerProgress(
playbackProgress = 0.2f,
pendingSeek = 0.8f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
assertThat(
shouldApplyPlayerProgress(
playbackProgress = 0.8f,
pendingSeek = 0.8f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `player samples apply immediately when there is no pending seek`() {
assertThat(
shouldApplyPlayerProgress(
playbackProgress = 0.2f,
pendingSeek = null,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `playing cursor snaps when duration is unknown`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.5f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 0,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `playing cursor does not snap when drift is exactly the sync threshold`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.22f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `playing cursor snaps back at the restart progress boundary`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.01f,
animatedProgress = 0.9f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.011f,
animatedProgress = 0.9f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
assertThat(
shouldSnapToPlayer(
playbackProgress = 0f,
animatedProgress = 0.89f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `faster playback treats the same progress delta as a smaller time gap`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.3f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 8f,
)
).isFalse()
}
@Test
fun `a pending seek is acknowledged when duration is unknown and the sample is close`() {
assertThat(
hasCaughtUpToSeek(
playbackProgress = 0.8f,
pendingSeek = 0.8f,
durationMs = 0,
playbackSpeed = 1f,
)
).isTrue()
assertThat(
hasCaughtUpToSeek(
playbackProgress = 0.82f,
pendingSeek = 0.8f,
durationMs = 0,
playbackSpeed = 1f,
)
).isFalse()
assertThat(
shouldApplyPlayerProgress(
playbackProgress = 0.805f,
pendingSeek = 0.8f,
durationMs = 0,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `a pending seek is acknowledged at the exact sync threshold`() {
assertThat(
hasCaughtUpToSeek(
playbackProgress = 0.82f,
pendingSeek = 0.8f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `zero playback speed still produces a finite remaining animation duration`() {
assertThat(
remainingPlaybackAnimationMs(
progress = 0.5f,
durationMs = 10_000,
playbackSpeed = 0f,
)
).isEqualTo(500_000)
}
@Test
fun `remaining animation duration is zero when the cursor is already at the end`() {
assertThat(
remainingPlaybackAnimationMs(
progress = 1f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isEqualTo(0)
assertThat(
remainingPlaybackAnimationMs(
progress = 1.2f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isEqualTo(0)
}
@Test
fun `remaining animation duration scales with leftover progress and speed`() {
assertThat(
remainingPlaybackAnimationMs(
progress = 0.25f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isEqualTo(7_500)
assertThat(
remainingPlaybackAnimationMs(
progress = 0.25f,
durationMs = 10_000,
playbackSpeed = 2f,
)
).isEqualTo(3_750)
}
}
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.libraries.designsystem.components.media
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.setSafeContent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class WaveformPlaybackViewTest : RobolectricTest() {
@Test
fun `seeking can be disabled`() = runAndroidComposeUiTest {
setWaveformPlaybackView(seekEnabled = false)
waitForIdle()
}
@Test
fun `a paused cursor follows a new player sample`() = runAndroidComposeUiTest {
val playbackProgress = mutableFloatStateOf(0.2f)
setWaveformPlaybackView(playbackProgress = { playbackProgress.value })
playbackProgress.value = 0.8f
waitForIdle()
}
@Test
fun `playing with a positive duration animates the cursor`() = runAndroidComposeUiTest {
setWaveformPlaybackView(
playbackProgress = { 0f },
isPlaying = true,
durationMs = 50L,
)
waitForIdle()
}
@Test
fun `playing at the end of the media snaps the cursor without tweening`() = runAndroidComposeUiTest {
setWaveformPlaybackView(
playbackProgress = { 1f },
isPlaying = true,
durationMs = 1_000L,
)
waitForIdle()
}
@Test
fun `playing with an unknown duration does not animate`() = runAndroidComposeUiTest {
setWaveformPlaybackView(
playbackProgress = { 0.3f },
isPlaying = true,
durationMs = 0L,
)
waitForIdle()
}
@Test
fun `playing cursor keeps interpolating when the player is only slightly ahead`() = runAndroidComposeUiTest {
val playbackProgress = mutableFloatStateOf(0.2f)
setWaveformPlaybackView(
playbackProgress = { playbackProgress.value },
isPlaying = true,
durationMs = 10_000L,
)
playbackProgress.value = 0.21f
waitForIdle()
}
@Test
fun `an empty waveform still renders with a cursor`() = runAndroidComposeUiTest {
setWaveformPlaybackView(
waveform = persistentListOf(),
showCursor = true,
)
waitForIdle()
}
@Test
fun `a short waveform still renders`() = runAndroidComposeUiTest {
setWaveformPlaybackView(waveform = persistentListOf(0.2f, 0.4f, 0.6f))
waitForIdle()
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setWaveformPlaybackView(
playbackProgress: () -> Float = { 0.5f },
showCursor: Boolean = false,
waveform: ImmutableList<Float> = WaveFormSamples.realisticWaveForm,
isPlaying: Boolean = false,
durationMs: Long = 0L,
playbackSpeed: Float = 1f,
seekEnabled: Boolean = true,
) {
setSafeContent {
ElementTheme {
WaveformPlaybackView(
playbackProgress = playbackProgress(),
showCursor = showCursor,
waveform = waveform,
onSeek = {},
modifier = Modifier
.fillMaxWidth()
.height(34.dp),
isPlaying = isPlaying,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
seekEnabled = seekEnabled,
)
}
}
}
@@ -57,7 +57,6 @@ import org.junit.Test
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@Suppress("LargeClass")
class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient
@@ -58,7 +58,6 @@ import org.junit.Test
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@Suppress("LargeClass")
class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient
@@ -21,7 +21,6 @@ import org.junit.Test
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@Suppress("LargeClass")
class RtcNotificationContentFormatterTest : RobolectricTest() {
private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.htmlrenderer.api"
}
dependencies {
api(projects.libraries.matrix.api)
api(libs.jsoup)
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.api
import org.jsoup.nodes.Document
/**
* Parses the HTML [Document] of a formatted message body into a tree of [MessageNode]s
* that a Compose renderer can turn into composables.
*
* The [Document] is expected to have already been sanitized (e.g. via
* `FormattedBody.toHtmlDocument`) so that only supported tags remain.
*/
interface HtmlMessageParser {
/**
* Converts [document] into a [DocumentNode]. Never throws: unknown or empty content
* yields a [DocumentNode] with no children.
*/
fun parse(document: Document): DocumentNode
companion object {
/**
* Tag used for the string annotation attached to link (`<a href>`) text ranges in a
* [ParagraphNode.text]. The annotation value is the link target URL.
*/
const val LINK_ANNOTATION_TAG = "url"
/**
* Tag used for the string annotation attached to inline code (`<code>`) text ranges in a
* [ParagraphNode.text]. The annotation value is empty; only the range matters. A `<code>`
* nested inside a `<pre>` is part of the [CodeBlockNode] and is never annotated this way.
*/
const val INLINE_CODE_ANNOTATION_TAG = "inline_code"
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.api
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
/**
* Describes a mention "pill" referenced from a [ParagraphNode].
*
* Each mention occupies an inline-content placeholder inside the paragraph's
* [ParagraphNode.text]; the renderer looks the id up in [ParagraphNode.inlineContent]
* and draws the corresponding pill.
*/
@Immutable
sealed interface MentionNodeContent {
/** The text shown inside the pill (e.g. a display name, prefixed with `@` for users). */
val displayText: String
/** A mention of a specific user (`<a href="matrix.to/#/@user:server">`). */
data class User(
override val displayText: String,
val userId: UserId,
) : MentionNodeContent
/** A mention of a room (`<a href="matrix.to/#/#room:server">`). */
data class Room(
override val displayText: String,
val roomIdOrAlias: RoomIdOrAlias,
) : MentionNodeContent
/** An `@room` mention notifying everyone in the room. */
data class Everyone(
override val displayText: String,
) : MentionNodeContent
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.api
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
/**
* A node in the parsed representation of a formatted (HTML) message body.
*
* The tree is produced by [HtmlMessageParser] and consumed by a Compose renderer.
* Block-level nodes ([BlockNode]) either contain other block nodes (nested lists, a
* quote containing paragraphs, ...) or terminate in a [ParagraphNode] / [CodeBlockNode]
* that carries the actual text.
*/
@Immutable
sealed interface MessageNode
/** A block-level node, laid out vertically by the renderer. */
@Immutable
sealed interface BlockNode : MessageNode
/**
* The root of a parsed message: an ordered list of block-level children.
*/
@Immutable
data class DocumentNode(
val children: ImmutableList<BlockNode>,
) : BlockNode
/**
* A run of inline content (text with inline formatting) already flattened to an
* [AnnotatedString]. This is the only leaf that carries styled text.
*
* Inline formatting (`<b>`, `<i>`, `<u>`, `<del>`, inline `<code>`) is encoded as
* [androidx.compose.ui.text.SpanStyle] ranges, and links (`<a>`) as string annotations
* tagged [HtmlMessageParser.LINK_ANNOTATION_TAG].
*
* Mentions (pills) are encoded as inline-content placeholders inside [text] (see
* [androidx.compose.foundation.text.appendInlineContent]); [inlineContent] maps each
* placeholder id to the [MentionNodeContent] the renderer needs to draw the pill.
*/
@Immutable
data class ParagraphNode(
val text: AnnotatedString,
val inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
) : BlockNode
/**
* A preformatted code block (`<pre>`). Rendered in a monospaced, horizontally
* scrollable container. The text is kept raw: no inline styling is applied inside it.
*/
@Immutable
data class CodeBlockNode(
val code: String,
) : BlockNode
/**
* A block quote (`<blockquote>`). Quotes can nest and can contain any block content.
*/
@Immutable
data class QuoteNode(
val children: ImmutableList<BlockNode>,
) : BlockNode
/**
* An ordered (`<ol>`) or unordered (`<ul>`) list.
*
* @param ordered whether the list is numbered (`<ol>`) or bulleted (`<ul>`).
* @param startIndex the number the first item of an ordered list starts at (honors `ol[start]`, default 1).
* @param items the list items, in document order.
*/
@Immutable
data class ListNode(
val ordered: Boolean,
val startIndex: Int,
val items: ImmutableList<ListItemNode>,
) : BlockNode
/**
* A single list item (`<li>`). Being a block container, an item can itself hold
* paragraphs, nested lists or quotes.
*/
@Immutable
data class ListItemNode(
val children: ImmutableList<BlockNode>,
) : MessageNode
@@ -0,0 +1,36 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.htmlrenderer.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
api(projects.libraries.htmlrenderer.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.uiCommon)
implementation(libs.jsoup)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
}
@@ -0,0 +1,237 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.impl
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.htmlrenderer.api.BlockNode
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.ListItemNode
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
/**
* Recursive-descent [HtmlMessageParser] over a (sanitized) jsoup [Document].
*
* Block-level elements become their matching [BlockNode]; consecutive inline nodes are
* coalesced into a single [ParagraphNode] whose text is an [AnnotatedString].
*/
@ContributesBinding(AppScope::class)
class DefaultHtmlMessageParser(
private val permalinkParser: PermalinkParser,
) : HtmlMessageParser {
override fun parse(document: Document): DocumentNode {
return DocumentNode(children = parseBlocks(document.body()))
}
/**
* Walks the children of [parent], emitting a block node for each block-level element and
* buffering runs of inline content into [ParagraphNode]s in between.
*/
private fun parseBlocks(parent: Element): ImmutableList<BlockNode> {
val blocks = mutableListOf<BlockNode>()
val inlineBuffer = mutableListOf<Node>()
fun flushInline() {
if (inlineBuffer.isNotEmpty()) {
buildParagraph(inlineBuffer)?.let(blocks::add)
inlineBuffer.clear()
}
}
for (node in parent.childNodes()) {
when (node) {
// The fallback reply content is never rendered.
is Element if node.tagName() == TAG_MX_REPLY -> Unit
is Element if node.isBlockLevel() -> {
flushInline()
parseBlock(node)?.let(blocks::add)
}
else -> inlineBuffer.add(node)
}
}
flushInline()
return blocks.toImmutableList()
}
private fun parseBlock(element: Element): BlockNode? = when (element.tagName()) {
TAG_P -> buildParagraph(element.childNodes())
TAG_PRE -> CodeBlockNode(code = element.wholeText().trimEnd('\n'))
TAG_BLOCKQUOTE -> QuoteNode(children = parseBlocks(element))
TAG_UL -> buildList(element, ordered = false)
TAG_OL -> buildList(element, ordered = true)
else -> null
}
private fun buildList(element: Element, ordered: Boolean): ListNode {
val startIndex = if (ordered) element.attr("start").toIntOrNull() ?: 1 else 1
val items = element.childNodes()
.filterIsInstance<Element>()
.filter { it.tagName() == TAG_LI }
.map { ListItemNode(children = parseBlocks(it)) }
.toImmutableList()
return ListNode(ordered = ordered, startIndex = startIndex, items = items)
}
/** Builds a [ParagraphNode] from a run of inline [nodes], or `null` if it holds no content. */
private fun buildParagraph(nodes: List<Node>): ParagraphNode? {
val builder = InlineContentBuilder()
nodes.forEach { builder.append(it) }
return builder.build()
}
/**
* Accumulates inline nodes into an [AnnotatedString] plus a map of mention placeholders.
* A fresh instance is used for each paragraph so placeholder ids stay local to it.
*/
private inner class InlineContentBuilder {
private val builder = AnnotatedString.Builder()
private val mentions = mutableMapOf<String, MentionNodeContent>()
private var mentionCount = 0
fun append(node: Node) {
when (node) {
is TextNode -> builder.append(node.text())
is Element -> appendElement(node)
else -> Unit
}
}
private fun appendElement(element: Element) {
when (element.tagName()) {
TAG_BR -> builder.append("\n")
TAG_B, TAG_STRONG -> withStyle(SpanStyle(fontWeight = FontWeight.Bold), element)
TAG_I, TAG_EM -> withStyle(SpanStyle(fontStyle = FontStyle.Italic), element)
TAG_U -> withStyle(SpanStyle(textDecoration = TextDecoration.Underline), element)
TAG_DEL, TAG_STRIKE_SHORT, TAG_STRIKE -> withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), element)
TAG_CODE -> appendInlineCode(element)
TAG_A -> appendLink(element)
TAG_MX_REPLY -> Unit
// Unknown inline wrapper: keep its text so nothing is dropped.
else -> appendChildren(element)
}
}
private fun appendChildren(element: Element) {
element.childNodes().forEach { append(it) }
}
private fun withStyle(style: SpanStyle, element: Element) {
val start = builder.length
appendChildren(element)
builder.addStyle(style, start, builder.length)
}
/**
* Renders an inline `<code>` as monospaced text and annotates the range so the renderer can
* apply a code background. A `<code>` inside a `<pre>` never reaches here: the `<pre>` is
* turned into a [CodeBlockNode] from its raw text.
*/
private fun appendInlineCode(element: Element) {
val start = builder.length
appendChildren(element)
if (builder.length > start) {
builder.addStyle(SpanStyle(fontFamily = FontFamily.Monospace), start, builder.length)
builder.addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, builder.length)
}
}
private fun appendLink(element: Element) {
val href = element.attr("href")
val mention = href.takeIf { it.isNotEmpty() }?.let { resolveMention(element, it) }
if (mention != null) {
val id = "$MENTION_ID_PREFIX${mentionCount++}"
mentions[id] = mention
builder.appendInlineContent(id, mention.displayText)
return
}
// Only the link range is annotated; the visual style (color, ...) is applied by the renderer.
val start = builder.length
appendChildren(element)
if (href.isNotEmpty() && builder.length > start) {
builder.addStringAnnotation(LINK_ANNOTATION_TAG, href, start, builder.length)
}
}
private fun resolveMention(element: Element, href: String): MentionNodeContent? {
return when (val permalink = permalinkParser.parse(href)) {
is PermalinkData.UserLink -> MentionNodeContent.User(
displayText = element.text(),
userId = permalink.userId,
)
is PermalinkData.RoomLink -> if (permalink.eventId == null) {
MentionNodeContent.Room(
displayText = element.text(),
roomIdOrAlias = permalink.roomIdOrAlias,
)
} else {
// A link to a specific event is a regular link, not a mention pill.
null
}
else -> null
}
}
fun build(): ParagraphNode? {
val text = builder.toAnnotatedString()
if (text.isBlank() && mentions.isEmpty()) return null
return ParagraphNode(
text = text,
inlineContent = mentions.toImmutableMap(),
)
}
}
}
/** Tags that start a new block-level node. Anything else is treated as inline content. */
private fun Element.isBlockLevel(): Boolean = tagName() in BLOCK_TAGS
private val BLOCK_TAGS = setOf(TAG_P, TAG_PRE, TAG_BLOCKQUOTE, TAG_UL, TAG_OL)
private const val TAG_P = "p"
private const val TAG_PRE = "pre"
private const val TAG_BLOCKQUOTE = "blockquote"
private const val TAG_UL = "ul"
private const val TAG_OL = "ol"
private const val TAG_LI = "li"
private const val TAG_BR = "br"
private const val TAG_B = "b"
private const val TAG_STRONG = "strong"
private const val TAG_I = "i"
private const val TAG_EM = "em"
private const val TAG_U = "u"
private const val TAG_DEL = "del"
private const val TAG_STRIKE_SHORT = "s"
private const val TAG_STRIKE = "strike"
private const val TAG_CODE = "code"
private const val TAG_A = "a"
private const val TAG_MX_REPLY = "mx-reply"
private const val MENTION_ID_PREFIX = "mention_"
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.impl.renderer
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.ListItemNode
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
import kotlinx.collections.immutable.persistentListOf
private val documentNode = DocumentNode(
persistentListOf(
ParagraphNode(AnnotatedString("Hello World, this is a")),
QuoteNode(persistentListOf(ParagraphNode(AnnotatedString("Quote with a very long text that wraps")))),
CodeBlockNode("fun main() {\n println(\"Hello, world!\")\n}"),
ListNode(
ordered = true,
startIndex = 0,
items = persistentListOf(
ListItemNode(persistentListOf(ParagraphNode(AnnotatedString("First item")))),
ListItemNode(persistentListOf(ParagraphNode(AnnotatedString("Second item")))),
ListItemNode(persistentListOf(ParagraphNode(AnnotatedString("Third item")))),
)
)
)
)
@Composable
private fun FakeTimestampView() {
Text(text = "12:34", modifier = Modifier.background(Color.LightGray).padding(start = 12.dp))
}
@Suppress("UnusedPrivateMember")
@Preview(showBackground = true)
@Composable
private fun ContentAvoidingLayoutHtmlMessageContentPreview() {
Box(modifier = Modifier.padding(WindowInsets.safeContent.asPaddingValues())) {
ContentAvoidingLayout(
overlay = { FakeTimestampView() },
content = {
HtmlMessageContent(
modifier = Modifier.background(Color.Red),
node = documentNode,
onContentLayoutChange = this::onContentLayoutChange,
)
}
)
}
}
@@ -0,0 +1,362 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.impl.renderer
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.htmlrenderer.api.BlockNode
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
/**
* Renders a parsed message tree ([DocumentNode]) as native Compose content.
*
* The renderer is stateless: it is driven entirely by [node] and reports interactions
* through the callbacks. It performs no HTML parsing itself.
*
* @param node the root HTML node of the parsed message tree to render.
* @param modifier applied to the root of the rendered content.
* @param currentUserId used to style a mention of the current user differently; may be null.
* @param onLinkClick invoked with the target URL when a link is tapped.
* @param onLinkLongClick invoked with the target URL when a link is long-pressed.
* @param onMentionClick invoked when a mention pill is tapped.
* @param onContentLayoutChange invoked with the measured layout of the very last rendered
* [ParagraphNode], so the timeline can lay out the timestamp around it. It is only wired to that
* last paragraph, and only when it is not nested inside a [CodeBlockNode] or [QuoteNode] — in which
* case no measurement is reported.
*/
@Composable
fun HtmlMessageContent(
node: DocumentNode,
modifier: Modifier = Modifier,
currentUserId: UserId? = null,
onLinkClick: (String) -> Unit = {},
onLinkLongClick: (String) -> Unit = {},
onMentionClick: (MentionNodeContent) -> Unit = {},
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) {
val measuredParagraph = remember(node) { node.lastBlockNode() }
val context = remember(currentUserId, onLinkClick, onLinkLongClick, onMentionClick, measuredParagraph, onContentLayoutChange) {
RenderContext(
currentUserId = currentUserId,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onMentionClick = onMentionClick,
lastBlockNode = measuredParagraph,
onContentLayoutChange = onContentLayoutChange,
)
}
SelectionContainer {
BlockNodes(nodes = node.children, context = context, modifier = modifier)
}
}
/** Interaction callbacks and state threaded through the render tree. */
@Immutable
private data class RenderContext(
val currentUserId: UserId?,
val onLinkClick: (String) -> Unit,
val onLinkLongClick: (String) -> Unit,
val onMentionClick: (MentionNodeContent) -> Unit,
// The latest descendant BlockNode of the root DocumentNode
val lastBlockNode: BlockNode?,
val onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
)
internal fun BlockNode.lastBlockNode(): BlockNode? = when (this) {
is ParagraphNode -> this
is DocumentNode -> children.lastOrNull()?.lastBlockNode()
is ListNode -> this
is QuoteNode -> this
is CodeBlockNode -> this
}
@Composable
private fun BlockNodes(
nodes: ImmutableList<BlockNode>,
context: RenderContext,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(BlockSpacing),
) {
nodes.forEach { BlockNodeView(node = it, context = context) }
}
}
@Composable
private fun BlockNodeView(
node: BlockNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
if (node === context.lastBlockNode && node !is ParagraphNode) {
SideEffect { context.onContentLayoutChange(ContentAvoidingLayoutData.NotOverlapping) }
}
when (node) {
is DocumentNode -> BlockNodes(nodes = node.children, context = context, modifier = modifier)
is ParagraphNode -> {
ParagraphView(node = node, context = context, modifier = modifier)
}
is CodeBlockNode -> CodeBlockView(node = node, modifier = modifier)
is QuoteNode -> QuoteView(node = node, context = context, modifier = modifier)
is ListNode -> ListView(node = node, context = context, modifier = modifier)
}
}
@Composable
private fun ParagraphView(
node: ParagraphNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
val linkColor = ElementTheme.colors.textLinkExternal
val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary
val styledText = remember(node.text, linkColor, codeBackgroundColor) {
node.text.applyInlineStyles(linkColor = linkColor, codeBackgroundColor = codeBackgroundColor)
}
val inlineContent = rememberMentionInlineContent(node.inlineContent, context)
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
// Only the last rendered paragraph (and only when not inside a code block / quote) reports its
// measured last line to the timeline.
val measureLastLine = if (node === context.lastBlockNode) {
ContentAvoidingLayout.measureLastTextLine(onContentLayoutChange = context.onContentLayoutChange)
} else {
null
}
Text(
text = styledText,
modifier = modifier.linkTapHandler(styledText, layoutResult, context),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
inlineContent = inlineContent,
onTextLayout = { result ->
layoutResult.value = result
measureLastLine?.invoke(result)
},
)
}
@Composable
private fun CodeBlockView(
node: CodeBlockNode,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(ElementTheme.colors.bgSubtleSecondary)
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
text = node.code,
style = ElementTheme.typography.fontBodyMdRegular.copy(fontFamily = FontFamily.Monospace),
color = ElementTheme.colors.textPrimary,
softWrap = false,
)
}
}
@Composable
private fun QuoteView(
node: QuoteNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier.height(IntrinsicSize.Min)) {
Box(
modifier = Modifier
.width(2.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(1.dp))
.background(ElementTheme.colors.borderInteractiveSecondary),
)
Spacer(modifier = Modifier.width(8.dp))
BlockNodes(nodes = node.children, context = context)
}
}
@Composable
private fun ListView(
node: ListNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(ListItemSpacing),
) {
node.items.forEachIndexed { index, item ->
Row {
Text(
text = if (node.ordered) "${node.startIndex + index}." else "•",
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.widthIn(min = ListMarkerWidth),
)
BlockNodes(nodes = item.children, context = context)
}
}
}
}
@Composable
private fun MentionPill(
mention: MentionNodeContent,
isCurrentUser: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val backgroundColor = if (isCurrentUser) ElementTheme.colors.bgBadgeAccent else ElementTheme.colors.bgBadgePrimary
val textColor = if (isCurrentUser) ElementTheme.colors.textBadgeAccent else ElementTheme.colors.textOnSolidPrimary
Box(
modifier = modifier
.fillMaxSize()
.clip(RoundedCornerShape(percent = 50))
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(horizontal = PillPaddingHorizontal, vertical = PillPaddingVertical),
contentAlignment = Alignment.Center,
) {
Text(
text = mention.displayText,
style = ElementTheme.typography.fontBodyLgMedium,
color = textColor,
maxLines = 1,
)
}
}
/**
* Builds an [InlineTextContent] entry for each mention placeholder. Each pill is measured so
* the placeholder reserves exactly the space the pill needs.
*/
@Composable
private fun rememberMentionInlineContent(
mentions: ImmutableMap<String, MentionNodeContent>,
context: RenderContext,
): ImmutableMap<String, InlineTextContent> {
if (mentions.isEmpty()) return persistentMapOf()
val textMeasurer = rememberTextMeasurer()
val pillTextStyle = ElementTheme.typography.fontBodyLgMedium
val density = LocalDensity.current
return mentions.mapValues { (_, mention) ->
val measured = textMeasurer.measure(mention.displayText, pillTextStyle)
val width = with(density) { measured.size.width.toDp() + PillPaddingHorizontal * 2 }
val height = with(density) { measured.size.height.toDp() + PillPaddingVertical * 2 }
InlineTextContent(
placeholder = Placeholder(
width = with(density) { width.toSp() },
height = with(density) { height.toSp() },
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
),
) {
MentionPill(
mention = mention,
isCurrentUser = mention is MentionNodeContent.User && mention.userId == context.currentUserId,
onClick = { context.onMentionClick(mention) },
)
}
}.toImmutableMap()
}
/** Overlays theme-dependent styling (link color, inline-code background) onto the annotated ranges. */
private fun AnnotatedString.applyInlineStyles(
linkColor: Color,
codeBackgroundColor: Color,
): AnnotatedString {
val linkRanges = getStringAnnotations(LINK_ANNOTATION_TAG, 0, length)
val codeRanges = getStringAnnotations(INLINE_CODE_ANNOTATION_TAG, 0, length)
if (linkRanges.isEmpty() && codeRanges.isEmpty()) return this
return buildAnnotatedString {
append(this@applyInlineStyles)
linkRanges.forEach { addStyle(SpanStyle(color = linkColor), it.start, it.end) }
codeRanges.forEach { addStyle(SpanStyle(background = codeBackgroundColor), it.start, it.end) }
}
}
private fun Modifier.linkTapHandler(
text: AnnotatedString,
layoutResult: State<TextLayoutResult?>,
context: RenderContext,
): Modifier = pointerInput(text) {
detectTapGestures(
onTap = { offset -> layoutResult.value?.urlAt(offset, text)?.let(context.onLinkClick) },
onLongPress = { offset -> layoutResult.value?.urlAt(offset, text)?.let(context.onLinkLongClick) },
)
}
private fun TextLayoutResult.urlAt(offset: Offset, text: AnnotatedString): String? {
val position = getOffsetForPosition(offset)
return text.getStringAnnotations(LINK_ANNOTATION_TAG, position, position).firstOrNull()?.item
}
private val BlockSpacing: Dp = 8.dp
private val ListItemSpacing: Dp = 4.dp
private val ListMarkerWidth: Dp = 24.dp
private val PillPaddingHorizontal: Dp = 6.dp
private val PillPaddingVertical: Dp = 2.dp
@@ -0,0 +1,212 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.impl.renderer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.htmlrenderer.api.BlockNode
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.ListItemNode
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
@PreviewsDayNight
@Composable
internal fun HtmlMessageContentPreview(
@PreviewParameter(HtmlMessageContentPreviewParam::class) node: DocumentNode,
) = ElementPreview {
HtmlMessageContent(
node = node,
modifier = Modifier.padding(16.dp),
currentUserId = UserId("@me:example.org"),
)
}
internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<DocumentNode> {
override val values: Sequence<DocumentNode>
get() = sequenceOf(
richParagraph(),
inlineCode(),
multilineInlineCode(),
orderedList(),
quote(),
nestedQuotes(),
codeBlock(),
mention(),
nestedList(),
nestedListWithComplexContents(),
)
private fun richParagraph() = document(
paragraph(
buildAnnotatedString {
append("Hello ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("bold") }
append(", ")
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append("italic") }
append(" and a ")
val start = length
append("link")
addStringAnnotation(LINK_ANNOTATION_TAG, "https://element.io", start, length)
}
),
)
private fun inlineCode() = document(
paragraph(
buildAnnotatedString {
append("Run ")
val start = length
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append("git status") }
addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length)
append(" to see changes")
}
),
)
private fun multilineInlineCode() = document(
QuoteNode(persistentListOf(
paragraph(
buildAnnotatedString {
append("Trying ")
val start = length
withStyle(
SpanStyle(fontFamily = FontFamily.Monospace)
) { append("inline code when it needs to be wrapped in several lines, just to check what it looks like") }
addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length)
append(", is it good?")
}
),
))
)
private fun orderedList() = document(
ListNode(
ordered = true,
startIndex = 1,
items = persistentListOf(
ListItemNode(persistentListOf(paragraph(AnnotatedString("First item")))),
ListItemNode(persistentListOf(paragraph(AnnotatedString("Second item")))),
),
),
)
private fun quote() = document(
paragraph(AnnotatedString("Someone said:")),
QuoteNode(persistentListOf(paragraph(AnnotatedString("This is a quoted message.")))),
)
private fun nestedQuotes() = document(
paragraph(AnnotatedString("Someone said:")),
QuoteNode(persistentListOf(
paragraph(AnnotatedString("This is a quoted message.")),
QuoteNode(persistentListOf(
paragraph(AnnotatedString("This is a nested quote.")),
codeBlock(),
)),
)),
)
private fun codeBlock() = document(
CodeBlockNode("fun main() {\n println(\"Hello, world!\")\n}"),
)
private fun mention() = document(
paragraph(
text = buildAnnotatedString {
append("Hey ")
appendInlineContent(MENTION_ID, "@alice")
append(", welcome!")
},
inlineContent = persistentMapOf(
MENTION_ID to MentionNodeContent.User(displayText = "@alice", userId = UserId("@alice:example.org")),
),
),
)
private fun nestedList() = document(
ListNode(
ordered = false,
startIndex = 1,
items = persistentListOf(
ListItemNode(
persistentListOf(
paragraph(AnnotatedString("Parent item")),
ListNode(
ordered = false,
startIndex = 1,
items = persistentListOf(
ListItemNode(persistentListOf(paragraph(AnnotatedString("Nested item")))),
),
),
),
),
),
),
)
private fun nestedListWithComplexContents() = document(
ListNode(
ordered = false,
startIndex = 1,
items = persistentListOf(
ListItemNode(
persistentListOf(
paragraph(AnnotatedString("Parent item")),
ListNode(
ordered = false,
startIndex = 1,
items = persistentListOf(
ListItemNode(persistentListOf(
paragraph(AnnotatedString("Nested item")),
codeBlock(),
quote(),
)),
),
),
),
),
),
),
)
private fun document(vararg blocks: BlockNode) = DocumentNode(blocks.toList().toImmutableList())
private fun paragraph(
text: AnnotatedString,
inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
) = ParagraphNode(text = text, inlineContent = inlineContent)
private companion object {
const val MENTION_ID = "mention_0"
}
}
@@ -0,0 +1,204 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.impl
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.jsoup.Jsoup
import org.junit.Test
class DefaultHtmlMessageParserTest : RobolectricTest() {
@Test
fun `plain text becomes a single paragraph`() {
val document = parse("Hello world")
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Hello world")
assertThat(paragraph.inlineContent).isEmpty()
}
@Test
fun `inline formatting is encoded as span styles`() {
val document = parse("Hello <strong>bold</strong> and <em>italic</em>")
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Hello bold and italic")
val boldStart = paragraph.text.text.indexOf("bold")
val bold = paragraph.text.spanStyles.single { it.item.fontWeight == FontWeight.Bold }
assertThat(bold.start).isEqualTo(boldStart)
assertThat(bold.end).isEqualTo(boldStart + "bold".length)
val italicStart = paragraph.text.text.indexOf("italic")
val italic = paragraph.text.spanStyles.single { it.item.fontStyle == FontStyle.Italic }
assertThat(italic.start).isEqualTo(italicStart)
assertThat(italic.end).isEqualTo(italicStart + "italic".length)
}
@Test
fun `unordered list produces a bulleted ListNode`() {
val document = parse("<ul><li>First</li><li>Second</li></ul>")
val list = document.children.single() as ListNode
assertThat(list.ordered).isFalse()
assertThat(list.startIndex).isEqualTo(1)
assertThat(list.items).hasSize(2)
val firstItem = list.items[0].children.single() as ParagraphNode
assertThat(firstItem.text.text).isEqualTo("First")
}
@Test
fun `ordered list honors the start attribute`() {
val document = parse("""<ol start="5"><li>Fifth</li></ol>""")
val list = document.children.single() as ListNode
assertThat(list.ordered).isTrue()
assertThat(list.startIndex).isEqualTo(5)
}
@Test
fun `a list nested inside a list item is preserved`() {
val document = parse("<ul><li>Parent<ul><li>Child</li></ul></li></ul>")
val outerList = document.children.single() as ListNode
val outerItem = outerList.items.single()
// The item holds a paragraph ("Parent") followed by the nested list.
val paragraph = outerItem.children[0] as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Parent")
val nestedList = outerItem.children[1] as ListNode
val nestedItem = nestedList.items.single().children.single() as ParagraphNode
assertThat(nestedItem.text.text).isEqualTo("Child")
}
@Test
fun `blockquote wraps its block children`() {
val document = parse("<blockquote><p>Quoted</p></blockquote>")
val quote = document.children.single() as QuoteNode
val paragraph = quote.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Quoted")
}
@Test
fun `blockquotes can nest`() {
val document = parse("<blockquote><blockquote><p>Deep</p></blockquote></blockquote>")
val outer = document.children.single() as QuoteNode
val inner = outer.children.single() as QuoteNode
val paragraph = inner.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Deep")
}
@Test
fun `inline code is monospaced and annotated`() {
val document = parse("Run <code>git status</code> now")
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Run git status now")
val start = paragraph.text.text.indexOf("git status")
val end = start + "git status".length
val monospace = paragraph.text.spanStyles.single { it.item.fontFamily == FontFamily.Monospace }
assertThat(monospace.start).isEqualTo(start)
assertThat(monospace.end).isEqualTo(end)
val codeAnnotations = paragraph.text.getStringAnnotations(HtmlMessageParser.INLINE_CODE_ANNOTATION_TAG, start, end)
assertThat(codeAnnotations).hasSize(1)
}
@Test
fun `pre becomes a code block with raw text, and its inner code is not treated as inline code`() {
val document = parse("<pre><code>val x = 1\nval y = 2</code></pre>")
val codeBlock = document.children.single() as CodeBlockNode
assertThat(codeBlock.code).isEqualTo("val x = 1\nval y = 2")
}
@Test
fun `a regular link is styled and annotated with its url`() {
val permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(android.net.Uri.parse(it)) }
val document = parse("""Visit <a href="https://element.io">the site</a>""", permalinkParser)
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.inlineContent).isEmpty()
val linkStart = paragraph.text.text.indexOf("the site")
val annotations = paragraph.text.getStringAnnotations(
tag = HtmlMessageParser.LINK_ANNOTATION_TAG,
start = linkStart,
end = linkStart + "the site".length,
)
assertThat(annotations.single().item).isEqualTo("https://element.io")
}
@Test
fun `a user link becomes a mention pill`() {
val userId = UserId("@alice:example.org")
val permalinkParser = FakePermalinkParser { PermalinkData.UserLink(userId) }
val document = parse("""Hi <a href="https://matrix.to/#/@alice:example.org">Alice</a>""", permalinkParser)
val paragraph = document.children.single() as ParagraphNode
val mention = paragraph.inlineContent.values.single()
assertThat(mention).isEqualTo(MentionNodeContent.User(displayText = "Alice", userId = userId))
// The placeholder id in the text matches the map key.
val placeholderId = paragraph.inlineContent.keys.single()
val annotations = paragraph.text.getStringAnnotations(0, paragraph.text.length)
assertThat(annotations.any { it.item == placeholderId }).isTrue()
}
@Test
fun `a room link becomes a room mention pill`() {
val roomIdOrAlias = RoomAlias("#room:example.org").toRoomIdOrAlias()
val permalinkParser = FakePermalinkParser {
PermalinkData.RoomLink(roomIdOrAlias = roomIdOrAlias)
}
val document = parse("""See <a href="https://matrix.to/#/#room:example.org">the room</a>""", permalinkParser)
val paragraph = document.children.single() as ParagraphNode
val mention = paragraph.inlineContent.values.single()
assertThat(mention).isEqualTo(
MentionNodeContent.Room(displayText = "the room", roomIdOrAlias = roomIdOrAlias)
)
}
@Test
fun `a link to an event is a regular link, not a mention`() {
val permalinkParser = FakePermalinkParser {
PermalinkData.RoomLink(
roomIdOrAlias = RoomId("!room:example.org").toRoomIdOrAlias(),
eventId = EventId("\$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg"),
)
}
val document = parse("""Look <a href="https://matrix.to/#/!room:example.org/event">here</a>""", permalinkParser)
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.inlineContent).isEmpty()
}
@Test
fun `mx-reply content is stripped`() {
val document = parse("<mx-reply>In reply to something</mx-reply>Actual message")
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Actual message")
}
@Test
fun `blank input produces an empty document`() {
val document = parse(" ")
assertThat(document.children).isEmpty()
}
private fun parse(
html: String,
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
) = DefaultHtmlMessageParser(permalinkParser).parse(Jsoup.parse(html))
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.htmlrenderer.test"
}
dependencies {
implementation(projects.libraries.htmlrenderer.api)
implementation(projects.tests.testutils)
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.test
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
import io.element.android.tests.testutils.lambda.lambdaError
import org.jsoup.nodes.Document
class FakeHtmlMessageParser(
private val parseResult: (Document) -> DocumentNode = { lambdaError() },
) : HtmlMessageParser {
override fun parse(document: Document): DocumentNode = parseResult(document)
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.accountprovider
import androidx.compose.runtime.Immutable
@Immutable
sealed interface AccountProvider {
/**
* An account provider that was input by the user.
*/
data class Generic(
val serverName: String,
) : AccountProvider
/**
* An account provider that is provided by the app.
*/
data class Managed(
val serverName: String,
val baseUrl: String,
val canUseServerName: Boolean,
) : AccountProvider
fun serverNameOrBaseUrl(): String {
return when (this) {
is Generic -> serverName
is Managed -> if (canUseServerName) serverName else baseUrl
}.sanitized()
}
/**
* Return the server name, to be used in the UI.
*/
fun friendlyServerName(): String {
return when (this) {
is Generic -> serverName
is Managed -> serverName
}.sanitized()
}
/**
* The user may reach an allowed account provider by either of its identifiers: the server name it is known by
* (what the user types, e.g. `element.io`) or the base url its client API lives at (e.g. `https://element.ems.host`).
*/
fun matches(other: AccountProvider): Boolean {
return serverNameOrBaseUrl() == other.serverNameOrBaseUrl()
}
/**
* Sanitizes the string with the following rules:
* - Trim any whitespace.
* - Lowercase the string.
* - Removes a https scheme (treating it as implicit).
* - Remove any trailing slashes.
*/
private fun String.sanitized(): String {
return trim()
.lowercase()
// Intentionally continue to show http:// as an indicator (and for history).
.removePrefix("https://")
.removeSuffix("/")
}
}
val matrixOrgAccountProvider = AccountProvider.Generic(
serverName = "matrix.org",
)
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.accountprovider
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class AccountProviderTest {
@Test
fun `serverNameOrBaseUrl of a generic account provider is the server name the user input`() {
assertThat(AccountProvider.Generic("matrix.org").serverNameOrBaseUrl()).isEqualTo("matrix.org")
}
@Test
fun `serverNameOrBaseUrl of a managed account provider is its server name when it can be used`() {
val accountProvider = AccountProvider.Managed(
serverName = "element.io",
baseUrl = "https://element.ems.host",
canUseServerName = true,
)
assertThat(accountProvider.serverNameOrBaseUrl()).isEqualTo("element.io")
}
@Test
fun `serverNameOrBaseUrl of a managed account provider is its base url when the server name cannot be used`() {
val accountProvider = AccountProvider.Managed(
serverName = "element.io",
baseUrl = "https://element.ems.host",
canUseServerName = false,
)
assertThat(accountProvider.serverNameOrBaseUrl()).isEqualTo("element.ems.host")
}
@Test
fun `matrixOrgAccountProvider is known by its server name`() {
assertThat(matrixOrgAccountProvider.serverNameOrBaseUrl()).isEqualTo("matrix.org")
}
@Test
fun `serverNameOrBaseUrl trims, lowercases and drops the implicit https scheme and a trailing slash`() {
assertThat(AccountProvider.Generic(" MATRIX.org ").serverNameOrBaseUrl()).isEqualTo("matrix.org")
assertThat(AccountProvider.Generic("https://matrix.org").serverNameOrBaseUrl()).isEqualTo("matrix.org")
assertThat(AccountProvider.Generic("https://matrix.org/").serverNameOrBaseUrl()).isEqualTo("matrix.org")
}
@Test
fun `serverNameOrBaseUrl keeps an explicit http scheme as an indicator`() {
assertThat(AccountProvider.Generic("http://localhost:8080").serverNameOrBaseUrl()).isEqualTo("http://localhost:8080")
}
@Test
fun `matches ignores the scheme, the case and a trailing slash`() {
val typed = AccountProvider.Generic("Element.io/")
assertThat(typed.matches(AccountProvider.Generic("https://element.io"))).isTrue()
assertThat(
typed.matches(
AccountProvider.Managed(serverName = "element.io", baseUrl = "https://element.ems.host", canUseServerName = true)
)
).isTrue()
}
@Test
fun `matches a managed provider that cannot use its server name only through its base url`() {
val managed = AccountProvider.Managed(
serverName = "element.io",
baseUrl = "https://element.ems.host",
canUseServerName = false,
)
assertThat(AccountProvider.Generic("element.ems.host").matches(managed)).isTrue()
assertThat(AccountProvider.Generic("element.io").matches(managed)).isFalse()
}
@Test
fun `matches is false for a different account provider`() {
val typed = AccountProvider.Generic("element.io")
assertThat(typed.matches(AccountProvider.Generic("matrix.org"))).isFalse()
// A managed provider that can use its server name is not reached through its base url.
assertThat(
typed.matches(
AccountProvider.Managed(serverName = "other.io", baseUrl = "https://element.io", canUseServerName = true)
)
).isFalse()
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.test.accountprovider
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL
fun anAccountProviderGeneric(
serverName: String = AN_ACCOUNT_PROVIDER,
) = AccountProvider.Generic(
serverName = serverName,
)
fun anAccountProviderManaged(
baseUrl: String = AN_ACCOUNT_PROVIDER_URL,
serverName: String = AN_ACCOUNT_PROVIDER,
canUseServerName: Boolean = true,
) = AccountProvider.Managed(
serverName = serverName,
baseUrl = baseUrl,
canUseServerName = canUseServerName,
)
@@ -54,7 +54,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Duration.Companion.minutes
@Suppress("LargeClass")
class InReplyToMetadataKtTest : RobolectricTest() {
@Test
fun `any message content`() = runTest {
@@ -153,6 +153,9 @@ private fun VoiceInfoRow(
onSeek = {
state.eventSink(VoiceMessageEvent.Seek(it))
},
isPlaying = state.isPlaying,
durationMs = state.durationMs,
playbackSpeed = state.playbackSpeed,
seekEnabled = !isValidating,
)
}
@@ -47,6 +47,7 @@ import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer
@@ -124,6 +125,7 @@ private fun ExoPlayerMediaAudioView(
canMute = false,
isMuted = false,
seekingToMillis = null,
playbackSpeed = exoPlayer.playbackParameters.speed,
)
)
}
@@ -156,6 +158,12 @@ private fun ExoPlayerMediaAudioView(
)
}
override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
playbackSpeed = playbackParameters.speed,
)
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
exoPlayer.duration.takeIf { it >= 0 }
@@ -276,6 +284,9 @@ private fun ExoPlayerMediaAudioView(
onSeek = {
exoPlayer.seekToEnsurePlaying((it * exoPlayer.duration).toLong())
},
isPlaying = mediaPlayerControllerState.isPlaying,
durationMs = mediaPlayerControllerState.durationInMillis,
playbackSpeed = mediaPlayerControllerState.playbackSpeed,
seekEnabled = true,
)
} else {
@@ -120,7 +120,9 @@ class ExoPlayerForPreview(
override fun seekToNext() {}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {}
override fun setPlaybackSpeed(speed: Float) {}
override fun getPlaybackParameters(): PlaybackParameters = throw NotImplementedError()
// Cannot throw, this method is invoked when recording Paparazzi screenshots.
override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
override fun stop() {}
override fun release() {}
override fun getCurrentTracks(): Tracks = throw NotImplementedError()
@@ -19,6 +19,7 @@ data class MediaPlayerControllerState(
val canMute: Boolean,
val isMuted: Boolean,
val seekingToMillis: Long?,
val playbackSpeed: Float,
) {
/**
* The progress in milliseconds to display. When [seekingToMillis] is non-null (during a seek operation),
@@ -35,6 +35,7 @@ private fun aMediaPlayerControllerState(
canMute: Boolean = true,
isMuted: Boolean = false,
seekingToMillis: Long? = null,
playbackSpeed: Float = 1f,
) = MediaPlayerControllerState(
isVisible = isVisible,
isPlaying = isPlaying,
@@ -44,4 +45,5 @@ private fun aMediaPlayerControllerState(
canMute = canMute,
isMuted = isMuted,
seekingToMillis = seekingToMillis,
playbackSpeed = playbackSpeed,
)
@@ -118,6 +118,7 @@ private fun ExoPlayerMediaVideoView(
canMute = true,
isMuted = false,
seekingToMillis = null,
playbackSpeed = exoPlayer.playbackParameters.speed,
)
)
}
@@ -62,7 +62,6 @@ import kotlinx.collections.immutable.toImmutableList
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class EventItemFactoryTest {
@Test
fun `create check all null cases`() {
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.libraries.mediaviewer.impl.local.audio
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.setSafeContent
import org.junit.Test
class MediaAudioViewTest : RobolectricTest() {
@Test
fun `an audio file without a waveform shows the filename`() = runAndroidComposeUiTest {
val info = anAudioMediaInfo(filename = "track.mp3")
setMediaAudioView(info = info)
onNodeWithText("track.mp3").assertIsDisplayed()
}
@Test
fun `an audio file with a waveform hides the filename in favour of the waveform`() = runAndroidComposeUiTest {
val info = anAudioMediaInfo(
filename = "voice.ogg",
waveForm = WaveFormSamples.realisticWaveForm,
)
setMediaAudioView(info = info)
onNodeWithText("voice.ogg").assertDoesNotExist()
}
@Test
fun `audio is paused when the view is not displayed`() = runAndroidComposeUiTest {
setMediaAudioView(isDisplayed = false)
waitForIdle()
}
@Test
fun `audio is paused when the host node is not visible`() = runAndroidComposeUiTest {
setMediaAudioView(isTargetVisible = false)
waitForIdle()
}
@Test
fun `a local media uri is prepared on the player`() = runAndroidComposeUiTest {
setMediaAudioView(
info = anAudioMediaInfo(
filename = "voice.ogg",
waveForm = WaveFormSamples.realisticWaveForm,
),
localMediaUri = Uri.parse("file://voice.ogg"),
)
waitForIdle()
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setMediaAudioView(
info: MediaInfo = anAudioMediaInfo(),
isDisplayed: Boolean = true,
isTargetVisible: Boolean = true,
localMediaUri: Uri? = null,
) {
setSafeContent {
CompositionLocalProvider(
LocalInspectionMode provides true,
LocalNodeTargetVisibility provides isTargetVisible,
) {
ElementTheme {
MediaAudioView(
localMediaViewState = rememberLocalMediaViewState(),
bottomPaddingInPixels = 0,
localMedia = localMediaUri?.let { aLocalMedia(uri = it, mediaInfo = info) },
info = info,
audioFocus = null,
isDisplayed = isDisplayed,
)
}
}
}
}
@@ -64,7 +64,6 @@ private val TESTED_MEDIA_INFO = anApkMediaInfo(
senderId = A_USER_ID,
)
@Suppress("LargeClass")
class MediaViewerPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -12,6 +12,7 @@ import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -31,18 +32,25 @@ import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationSto
import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived
import io.element.android.libraries.push.impl.push.OnRedactedEventReceived
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import timber.log.Timber
private const val TAG = "NotifResultProcessor"
interface NotificationResultProcessor {
/**
* Queue a batch of resolved push events for processing and suspend until it has been processed, so that the caller can keep the process
* alive (e.g. through a foreground service) until any resulting notification or ringing call has been set up.
*/
suspend fun emit(results: Map<PushRequest, Result<ResolvedPushEvent>>)
fun start()
fun stop()
@@ -61,23 +69,50 @@ class DefaultNotificationResultProcessor(
private val notificationChannels: NotificationChannels,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
) : NotificationResultProcessor {
private val resultFlow = MutableSharedFlow<Map<PushRequest, Result<ResolvedPushEvent>>>(extraBufferCapacity = Int.MAX_VALUE)
private data class PendingResults(
val results: Map<PushRequest, Result<ResolvedPushEvent>>,
val processed: CompletableDeferred<Unit> = CompletableDeferred(),
)
// A channel rather than a shared flow: it keeps the results until the collector takes them, so nothing is dropped if the collector isn't
// running yet (e.g. the worker runs in a process where no push has been received, after a WorkManager retry) or hasn't subscribed yet.
private val pendingResults = Channel<PendingResults>(Channel.UNLIMITED)
private var processJob: Job? = null
override suspend fun emit(results: Map<PushRequest, Result<ResolvedPushEvent>>) {
resultFlow.emit(results)
val collector = ensureStarted()
val pending = PendingResults(results)
pendingResults.send(pending)
// Suspend until the batch has been processed, or give up if the collector was stopped in the meantime.
select<Unit> {
pending.processed.onAwait {}
collector.onJoin { Timber.tag(TAG).w("Processing stopped before the results were processed") }
}
}
override fun start() {
if (processJob?.isActive == true) {
Timber.tag(TAG).w("Is already processing, not starting again")
return
}
processJob = resultFlow
.onEach(::processResults)
.launchIn(coroutineScope)
ensureStarted()
}
@Synchronized
private fun ensureStarted(): Job {
processJob?.takeIf { it.isActive }?.let { return it }
return pendingResults
.receiveAsFlow()
.onEach { pending ->
try {
// Don't let a failing batch cancel the collector, or subsequent batches would never be processed.
runCatchingExceptions { processResults(pending.results) }
.onFailure { Timber.tag(TAG).e(it, "Failed to process notification results") }
} finally {
pending.processed.complete(Unit)
}
}
.launchIn(coroutineScope)
.also { processJob = it }
}
@Synchronized
override fun stop() {
if (processJob?.isActive != true) {
Timber.tag(TAG).w("Is not processing, not stopping")
@@ -69,7 +69,6 @@ import org.junit.Test
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@Suppress("LargeClass")
class DefaultNotifiableEventResolverTest : RobolectricTest() {
@Test
fun `resolve event no session`() = runTest {
@@ -303,6 +303,48 @@ class DefaultNotificationResultProcessorTest {
)
}
@Test
fun `emit starts the processing if needed and only returns once the results have been processed`() = runTest {
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val processor = createDefaultNotificationResultProcessor(
pushHistoryService = FakePushHistoryService(onPushReceivedResult = { _, _, _, _, _, _, _ -> }),
onNotifiableEventsReceived = onNotifiableEventsReceived,
)
// No call to start(): the worker may run in a process where no push has been received yet
processor.emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent()))))
// No runCurrent(): the results must already have been processed when emit returns
onNotifiableEventsReceived.assertions().isCalledOnce()
processor.stop()
}
@Test
fun `a failing batch does not prevent later batches from being processed`() = runTest {
var pushHistoryCalls = 0
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = { _, _, _, _, _, _, _ ->
pushHistoryCalls++
if (pushHistoryCalls == 1) error("boom")
},
)
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val processor = createDefaultNotificationResultProcessor(
pushHistoryService = pushHistoryService,
onNotifiableEventsReceived = onNotifiableEventsReceived,
)
runningProcessor(processor) {
// Processing the first batch throws, but emit must still return and the collector must survive
emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent()))))
emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent()))))
}
// Only the second batch made it to the notification drawer
onNotifiableEventsReceived.assertions().isCalledOnce()
}
private suspend fun TestScope.runningProcessor(processor: NotificationResultProcessor, block: suspend NotificationResultProcessor.() -> Unit) {
processor.start()
@@ -52,7 +52,6 @@ import kotlin.time.Duration.Companion.milliseconds
private const val A_PUSHER_INFO = "info"
@Suppress("LargeClass")
class DefaultPushHandlerTest {
@Test
fun `check handleInvalid behavior`() = runTest {
@@ -370,6 +370,7 @@ fun TextComposer(
waveform = voiceMessageState.waveform,
playbackProgress = voiceMessageState.playbackProgress,
time = voiceMessageState.time,
duration = voiceMessageState.duration,
onPlayClick = onPlayVoiceMessageClick,
onPauseClick = onPauseVoiceMessageClick,
onSeek = onSeekVoiceMessage,
@@ -929,6 +930,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
showCursor = false,
waveform = WaveFormSamples.realisticWaveForm,
time = 0.seconds,
duration = 61.seconds,
playbackProgress = 0.0f,
),
VoiceMessageState.Preview(
@@ -937,6 +939,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
showCursor = true,
waveform = WaveFormSamples.realisticWaveForm,
time = 3.seconds,
duration = 0.seconds,
playbackProgress = 0.2f,
),
VoiceMessageState.Preview(
@@ -945,6 +948,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
showCursor = false,
waveform = WaveFormSamples.realisticWaveForm,
time = 61.seconds,
duration = 61.seconds,
playbackProgress = 0.0f,
),
)
@@ -972,6 +976,7 @@ internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview {
showCursor = false,
waveform = WaveFormSamples.realisticWaveForm,
time = 0.seconds,
duration = 61.seconds,
playbackProgress = 0.0f
),
VoiceMessageState.Preview(
@@ -980,6 +985,7 @@ internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview {
showCursor = true,
waveform = WaveFormSamples.realisticWaveForm,
time = 3.seconds,
duration = 0.seconds,
playbackProgress = 0.2f
),
VoiceMessageState.Preview(
@@ -988,6 +994,7 @@ internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview {
showCursor = false,
waveform = WaveFormSamples.realisticWaveForm,
time = 61.seconds,
duration = 61.seconds,
playbackProgress = 0.0f
),
)
@@ -50,6 +50,7 @@ internal fun VoiceMessagePreview(
showCursor: Boolean,
waveform: ImmutableList<Float>,
time: Duration,
duration: Duration,
onPlayClick: () -> Unit,
onPauseClick: () -> Unit,
onSeek: (Float) -> Unit,
@@ -88,6 +89,8 @@ internal fun VoiceMessagePreview(
playbackProgress = playbackProgress,
showCursor = showCursor,
waveform = waveform,
isPlaying = isPlaying,
durationMs = duration.inWholeMilliseconds,
seekEnabled = true,
onSeek = onSeek,
)
@@ -151,6 +154,8 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview {
isInteractive = true,
isPlaying = true,
time = 2.seconds,
// Keep previews static: a non-zero duration would start a long linear tween.
duration = 0.seconds,
playbackProgress = 0.2f,
showCursor = true,
waveform = WaveFormSamples.longRealisticWaveForm,
@@ -159,6 +164,7 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview {
isInteractive = true,
isPlaying = false,
time = 0.seconds,
duration = 10.seconds,
playbackProgress = 0.0f,
showCursor = true,
waveform = WaveFormSamples.longRealisticWaveForm,
@@ -167,6 +173,7 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview {
isInteractive = false,
isPlaying = false,
time = 789.seconds,
duration = 789.seconds,
playbackProgress = 0.0f,
showCursor = false,
waveform = WaveFormSamples.longRealisticWaveForm,
@@ -179,6 +186,7 @@ private fun AVoiceMessagePreview(
isInteractive: Boolean,
isPlaying: Boolean,
time: Duration,
duration: Duration,
playbackProgress: Float,
showCursor: Boolean,
waveform: ImmutableList<Float>,
@@ -187,6 +195,7 @@ private fun AVoiceMessagePreview(
isInteractive = isInteractive,
isPlaying = isPlaying,
time = time,
duration = duration,
playbackProgress = playbackProgress,
showCursor = showCursor,
waveform = waveform,
@@ -22,6 +22,7 @@ sealed interface VoiceMessageState {
val showCursor: Boolean,
val playbackProgress: Float,
val time: Duration,
val duration: Duration,
// Values are between 0 and 1
val waveform: ImmutableList<Float>,
) : VoiceMessageState
+1
View File
@@ -16,5 +16,6 @@ android {
dependencies {
implementation(libs.appyx.core)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
}
@@ -0,0 +1,214 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.ui.common.layout
import android.text.Layout
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* A layout with 2 children: the [content] and the [overlay].
*
* It will try to place the [overlay] on top of the [content] if possible, avoiding the area of it that is non-overlapping.
* If the [overlay] can't be placed on top of the [content], it will be placed to the right of it, if it fits, otherwise, to its bottom in a new row.
*
* @param overlay The 'overlay' component of the layout, which will be positioned relative to the [content].
* @param modifier The modifier for the layout.
* @param spacing The spacing between the [content] and the [overlay]. Defaults to `0.dp`.
* @param overlayOffset The offset of the [overlay] from the bottom right corner of the [content].
* @param shrinkContent Whether the content should be shrunk to fit the available width or not. Defaults to `false`.
* @param content The 'content' component of the layout.
*/
@Suppress("ContentSlotReused") // Since we added an exception for `LocalUiTestMode`, detekt thinks the layout can change in runtime: it won't
@Composable
fun ContentAvoidingLayout(
overlay: @Composable () -> Unit,
modifier: Modifier = Modifier,
spacing: Dp = 0.dp,
overlayOffset: DpOffset = DpOffset.Zero,
shrinkContent: Boolean = false,
content: @Composable ContentAvoidingLayoutScope.() -> Unit,
) {
val scope = remember { ContentAvoidingLayoutScopeInstance() }
// Custom layouts don't seem to work well with Compose UI tests (they crash), so we use a Column instead when running in test mode.
if (LocalUiTestMode.current) {
Column {
scope.content()
overlay()
}
return
}
Layout(
modifier = modifier,
content = {
scope.content()
overlay()
}
) { measurables, constraints ->
// Measure the `overlay` view first, in case we need to shrink the `content`
val overlayPlaceable = measurables.last().measure(Constraints(minWidth = 0, maxWidth = constraints.maxWidth))
val contentConstraints = if (shrinkContent) {
Constraints(minWidth = 0, maxWidth = constraints.maxWidth - overlayPlaceable.width)
} else {
Constraints(minWidth = 0, maxWidth = constraints.maxWidth)
}
val contentPlaceable = measurables.first().measure(contentConstraints)
var layoutWidth = contentPlaceable.width
var layoutHeight = contentPlaceable.height
val data = scope.data.value
// Free space = width of the whole component - width of its non overlapping contents
val freeSpace = max(contentPlaceable.width - data.nonOverlappingContentWidth, 0)
when {
// When the content + the overlay don't fit in the available max width, we need to move the overlay to a new row
!data.canOverlay() || !shrinkContent && data.nonOverlappingContentWidth + overlayPlaceable.width > constraints.maxWidth -> {
layoutHeight += overlayPlaceable.height + overlayOffset.y.roundToPx()
}
// If the content is smaller than the available max width, we can move the overlay to the right of the content
contentPlaceable.width < constraints.maxWidth -> {
// If both the content and the overlay plus the padding can fit inside the current layoutWidth, there is no need to increase it
if (freeSpace < overlayPlaceable.width + spacing.roundToPx()) {
// Otherwise, we need to increase it by the width of the overlay + some padding adjustments
val calculatedWidth = max(data.nonOverlappingContentWidth + overlayPlaceable.width + spacing.roundToPx(), contentPlaceable.width)
layoutWidth = min(calculatedWidth, constraints.maxWidth)
}
}
else -> Unit
}
layoutWidth = max(layoutWidth, constraints.minWidth)
layoutHeight = max(layoutHeight, constraints.minHeight)
layout(layoutWidth, layoutHeight) {
contentPlaceable.placeRelative(0, 0)
overlayPlaceable.placeRelative(layoutWidth - overlayPlaceable.width, layoutHeight - overlayPlaceable.height + overlayOffset.y.roundToPx())
}
}
}
/**
* Data class to hold the content layout data.
* This is used to pass the data from the content to the [ContentAvoidingLayout].
*
* @param contentWidth The full width of the content in pixels.
* @param contentHeight The full height of the content in pixels.
* @param nonOverlappingContentWidth The width of the part of the content that can't overlap with the timestamp.
* @param nonOverlappingContentHeight The height of the part of the content that can't overlap with the timestamp.
*/
data class ContentAvoidingLayoutData(
val contentWidth: Int = 0,
val contentHeight: Int = 0,
val nonOverlappingContentWidth: Int = contentWidth,
val nonOverlappingContentHeight: Int = contentHeight,
) {
fun canOverlay(): Boolean = contentWidth != Int.MAX_VALUE
companion object {
val NotOverlapping = ContentAvoidingLayoutData(contentWidth = Int.MAX_VALUE, contentHeight = Int.MAX_VALUE)
}
}
/**
* A scope for the [ContentAvoidingLayout].
*/
interface ContentAvoidingLayoutScope {
/**
* It should be called when the content layout changes, so it can update the [ContentAvoidingLayoutData] and measure and layout the content properly.
*/
fun onContentLayoutChange(data: ContentAvoidingLayoutData)
}
private class ContentAvoidingLayoutScopeInstance(
val data: MutableState<ContentAvoidingLayoutData> = mutableStateOf(ContentAvoidingLayoutData()),
) : ContentAvoidingLayoutScope {
override fun onContentLayoutChange(data: ContentAvoidingLayoutData) {
this.data.value = data
}
}
object ContentAvoidingLayout {
/**
* Measures the last line of a [TextLayoutResult] and calls [onContentLayoutChange] with the [ContentAvoidingLayoutData].
*
* This is supposed to be used in the `onTextLayout` parameter of a Text based component.
*/
@Composable
fun measureLastTextLine(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
extraWidth: Dp = 0.dp,
): ((TextLayoutResult) -> Unit) {
val extraWidthPx = extraWidth.roundToPx()
return { textLayout: TextLayoutResult ->
// We need to add the external extra width so it's not taken into account as 'free space'
val textDirection = tryOrNull { textLayout.getParagraphDirection(0) }
val lastLineWidth = when (textDirection) {
ResolvedTextDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt()
else -> textLayout.getLineRight(textLayout.lineCount - 1).roundToInt()
}
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1).roundToInt()
onContentLayoutChange(
ContentAvoidingLayoutData(
contentWidth = textLayout.size.width + extraWidthPx,
contentHeight = textLayout.size.height,
nonOverlappingContentWidth = lastLineWidth + extraWidthPx,
nonOverlappingContentHeight = lastLineHeight,
)
)
}
}
/**
* Measures the last line of a [Layout] and calls [onContentLayoutChange] with the [ContentAvoidingLayoutData].
*
* This is supposed to be used in the `onTextLayout` parameter of an [EditorStyledText] component.
*/
@Composable
fun measureLegacyLastTextLine(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
extraWidth: Dp = 0.dp,
): ((Layout) -> Unit) {
val extraWidthPx = extraWidth.roundToPx()
return { textLayout: Layout ->
// We need to add the external extra width so it's not taken into account as 'free space'
val lastLineWidth = textLayout.getLineWidth(textLayout.lineCount - 1).roundToInt()
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1)
onContentLayoutChange(
ContentAvoidingLayoutData(
contentWidth = textLayout.width + extraWidthPx,
contentHeight = textLayout.height,
nonOverlappingContentWidth = lastLineWidth + extraWidthPx,
nonOverlappingContentHeight = lastLineHeight,
)
)
}
}
}
@@ -13,7 +13,9 @@ data class VoiceMessageState(
val progress: Float,
val time: String,
val showCursor: Boolean,
val isPlaying: Boolean,
val playbackSpeed: Float,
val durationMs: Long,
val eventSink: (event: VoiceMessageEvent) -> Unit,
) {
enum class ButtonType {
@@ -34,6 +34,8 @@ open class VoiceMessageStatePreviewParam : PreviewParameterProvider<VoiceMessage
progress = 0.2f,
time = "10:00",
showCursor = true,
isPlaying = true,
durationMs = 0,
),
aVoiceMessageState(
VoiceMessageState.ButtonType.Disabled,
@@ -48,12 +50,16 @@ fun aVoiceMessageState(
progress: Float = 0f,
time: String = "1:00",
showCursor: Boolean = false,
isPlaying: Boolean = false,
playbackSpeed: Float = 1.0f,
durationMs: Long = 60_000,
) = VoiceMessageState(
buttonType = buttonType,
progress = progress,
time = time,
showCursor = showCursor,
isPlaying = isPlaying,
playbackSpeed = playbackSpeed,
durationMs = durationMs,
eventSink = {},
)
@@ -135,7 +135,9 @@ class VoiceMessagePresenter(
progress = progress,
time = time,
showCursor = showCursor,
isPlaying = playerState.isPlaying,
playbackSpeed = VoicePlayerConfig.availablePlaybackSpeeds[playbackSpeedIndex],
durationMs = duration,
eventSink = ::handleEvent,
)
}
@@ -34,6 +34,8 @@ class VoiceMessagePresenterTest {
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("1:01")
assertThat(it.isPlaying).isFalse()
assertThat(it.durationMs).isEqualTo(61_000)
}
}
}