mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 01:19:33 +00:00
Merge branch 'develop' into sync-localazy
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+72
@@ -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
|
||||
}
|
||||
+159
@@ -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)
|
||||
}
|
||||
+158
-33
@@ -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 {
|
||||
|
||||
+134
@@ -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?,
|
||||
)
|
||||
}
|
||||
+280
@@ -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)
|
||||
}
|
||||
}
|
||||
+127
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -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
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
-1
@@ -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)
|
||||
}
|
||||
+40
@@ -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"
|
||||
}
|
||||
}
|
||||
+42
@@ -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
|
||||
}
|
||||
+95
@@ -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)
|
||||
}
|
||||
+237
@@ -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_"
|
||||
+70
@@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+362
@@ -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
|
||||
+212
@@ -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"
|
||||
}
|
||||
}
|
||||
+204
@@ -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)
|
||||
}
|
||||
+19
@@ -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)
|
||||
}
|
||||
+73
@@ -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",
|
||||
)
|
||||
+89
@@ -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()
|
||||
}
|
||||
}
|
||||
+28
@@ -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,
|
||||
)
|
||||
-1
@@ -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 {
|
||||
|
||||
+3
@@ -153,6 +153,9 @@ private fun VoiceInfoRow(
|
||||
onSeek = {
|
||||
state.eventSink(VoiceMessageEvent.Seek(it))
|
||||
},
|
||||
isPlaying = state.isPlaying,
|
||||
durationMs = state.durationMs,
|
||||
playbackSpeed = state.playbackSpeed,
|
||||
seekEnabled = !isValidating,
|
||||
)
|
||||
}
|
||||
|
||||
+11
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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()
|
||||
|
||||
+1
@@ -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),
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+1
@@ -118,6 +118,7 @@ private fun ExoPlayerMediaVideoView(
|
||||
canMute = true,
|
||||
isMuted = false,
|
||||
seekingToMillis = null,
|
||||
playbackSpeed = exoPlayer.playbackParameters.speed,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
-1
@@ -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`() {
|
||||
|
||||
+98
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -64,7 +64,6 @@ private val TESTED_MEDIA_INFO = anApkMediaInfo(
|
||||
senderId = A_USER_ID,
|
||||
)
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class MediaViewerPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
+45
-10
@@ -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")
|
||||
|
||||
-1
@@ -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 {
|
||||
|
||||
+42
@@ -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()
|
||||
|
||||
|
||||
-1
@@ -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 {
|
||||
|
||||
+7
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
@@ -16,5 +16,6 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(libs.appyx.core)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
}
|
||||
|
||||
+214
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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 {
|
||||
|
||||
+6
@@ -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 = {},
|
||||
)
|
||||
|
||||
+2
@@ -135,7 +135,9 @@ class VoiceMessagePresenter(
|
||||
progress = progress,
|
||||
time = time,
|
||||
showCursor = showCursor,
|
||||
isPlaying = playerState.isPlaying,
|
||||
playbackSpeed = VoicePlayerConfig.availablePlaybackSpeeds[playbackSpeedIndex],
|
||||
durationMs = duration,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user