Merge branch 'main' into wallet

# Conflicts:
#	features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
#	features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt
#	features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
#	features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
#	features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
#	features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt
#	features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt
#	features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt
#	features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt
#	libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt
This commit is contained in:
Cobb 2026-04-16 22:05:16 -07:00
commit 0ef6b69a79
912 changed files with 17051 additions and 4425 deletions

View file

@ -0,0 +1,33 @@
/*
* 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.androidutils.service
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.annotations.ApplicationContext
interface ServiceBinder {
fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean
fun unbindService(conn: ServiceConnection)
}
@ContributesBinding(AppScope::class)
class DefaultServiceBinder(
@ApplicationContext private val context: Context,
) : ServiceBinder {
override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
return context.bindService(service, conn, flags)
}
override fun unbindService(conn: ServiceConnection) {
context.unbindService(conn)
}
}

View file

@ -16,9 +16,11 @@ import android.view.ViewTreeObserver
import android.view.WindowInsets
import android.view.inputmethod.InputMethodManager
import androidx.core.content.getSystemService
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.seconds
fun View.hideKeyboard() {
val imm = context?.getSystemService<InputMethodManager>()
@ -26,29 +28,39 @@ fun View.hideKeyboard() {
}
suspend fun View.hideKeyboardAndAwaitAnimation() {
val imm = context?.getSystemService<InputMethodManager>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !rootWindowInsets.isVisible(WindowInsets.Type.ime())) {
// Keyboard is already hidden, no need to do anything
return
}
val mutex = Mutex()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val imm = context?.getSystemService<InputMethodManager>() ?: return
val future = CompletableDeferred<Unit>()
val requested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setOnApplyWindowInsetsListener { view, insets ->
if (!insets.isVisible(WindowInsets.Type.ime())) {
mutex.unlock()
future.complete(Unit)
// Remove the listener now, it's a single use operation
setOnApplyWindowInsetsListener(null)
}
insets
}
imm?.hideSoftInputFromWindow(windowToken, 0)
imm.hideSoftInputFromWindow(windowToken, 0)
} else {
@Suppress("DEPRECATION")
imm?.hideSoftInputFromWindow(windowToken, 0, object : ResultReceiver(null) {
imm.hideSoftInputFromWindow(windowToken, 0, object : ResultReceiver(null) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN ||
resultCode == InputMethodManager.RESULT_HIDDEN) {
mutex.unlock()
if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN || resultCode == InputMethodManager.RESULT_HIDDEN) {
future.complete(Unit)
}
}
})
}
mutex.lock()
if (requested) {
// Await the future to ensure the keyboard hide animation has completed before proceeding
withTimeoutOrNull(1.seconds) { future.await() }
}
}
fun View.showKeyboard(andRequestFocus: Boolean = false) {

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"このアクションを処理できるアプリが見つかりません。"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Không tìm thấy ứng dụng tương thích nào để xử lý hành động này."</string>
</resources>

View file

@ -0,0 +1,54 @@
/*
* 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.architecture.appyx
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
import com.bumble.appyx.navmodel.backstack.operation.Replace
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
/**
* A TransitionHandler that uses fade transition when the operation is Replace or NewRoot,
* and slide transition for all other cases.
*/
private class FaderOrSliderTransitionHandler<NavTarget>(
private val slider: ModifierTransitionHandler<NavTarget, BackStack.State>,
private val fader: ModifierTransitionHandler<NavTarget, BackStack.State>,
) : ModifierTransitionHandler<NavTarget, BackStack.State>() {
override fun createModifier(
modifier: Modifier,
transition: Transition<BackStack.State>,
descriptor: TransitionDescriptor<NavTarget, BackStack.State>
): Modifier {
val operation = descriptor.operation
val useFader = operation is Replace || operation is NewRoot
val handler = if (useFader) fader else slider
return handler.createModifier(modifier, transition, descriptor)
}
}
@Composable
fun <NavTarget> rememberFaderOrSliderTransitionHandler(): ModifierTransitionHandler<NavTarget, BackStack.State> {
val slider = rememberBackstackSlider<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
val fader = rememberBackstackFader<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
return rememberDelegateTransitionHandler {
FaderOrSliderTransitionHandler(slider, fader)
}
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:37f6acca46890e98087ece62e2716fa60791479fab02999406050517e3b79307
size 240187
oid sha256:7901fea2f578c8ed796160c9c08f417c61f4fb21580f958844fdf0cb794adf8a
size 239731

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a2de5e6d24dcbe0baa75a69485f5a308466fa599625bcbdb0cb96e9bc5a1b708
size 253233
oid sha256:245f012d419817f6557d92a71729b3b70092f24f0eba37f2f1fc431ad27592be
size 252969

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae1cb46d82acbb23cc172f41e20a41bbe88c350ab53c20e5b2a91f2c16590fbf
size 254525
oid sha256:2d15c52b21cc279d306fa187cd0c318820109b5ec66270e6447e1b02e800eeba
size 254206

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a8a9b6e61758a40d01028a4edb4a4d21b845b83b3e0793ed0934e48f3d9eea0
size 94637
oid sha256:85ef188fa3a27e42f4beafc899c1f3e7e8bcfad980ed76af6a03f76d70d6a511
size 93807

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7f29d225df71587fefe07ec8739b84f1a0469786c6b1d6778da0bad33d19574e
size 101183
oid sha256:f6e38386e95dc0c50384f06fca122ce14851ceff8ffc7865e394c1b4fccc5db6
size 100555

File diff suppressed because one or more lines are too long

View file

@ -229,9 +229,6 @@ object CompoundIcons {
@Composable fun Filter(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_filter)
}
@Composable fun Folder(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_folder)
}
@Composable fun Forward(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_forward)
}
@ -771,7 +768,6 @@ object CompoundIcons {
FileError(),
Files(),
Filter(),
Folder(),
Forward(),
FullScreen(),
Grid(),
@ -1000,7 +996,6 @@ object CompoundIcons {
R.drawable.ic_compound_file_error,
R.drawable.ic_compound_files,
R.drawable.ic_compound_filter,
R.drawable.ic_compound_folder,
R.drawable.ic_compound_forward,
R.drawable.ic_compound_full_screen,
R.drawable.ic_compound_grid,

View file

@ -91,6 +91,8 @@ data class SemanticColors(
val bgSubtleSecondaryLevel0: Color,
/** Subtle background colour for success state elements. State: Rest. */
val bgSuccessSubtle: Color,
/** Accent borders for containers */
val borderAccentPrimary: Color,
/** accent border intended for keylines on message highlights */
val borderAccentSubtle: Color,
/** High-contrast border for critical state. State: Hover. */
@ -171,6 +173,8 @@ data class SemanticColors(
val iconTertiary: Color,
/** Translucent version of tertiary icon. Refer to it for intended use. */
val iconTertiaryAlpha: Color,
/** Used to separate core sections of the UI as well as containers */
val separatorPrimary: Color,
/** Accent text colour for plain actions. */
val textActionAccent: Color,
/** Default text colour for plain actions. */

View file

@ -61,6 +61,7 @@ val compoundColorsDark = SemanticColors(
bgSubtleSecondary = DarkColorTokens.colorGray300,
bgSubtleSecondaryLevel0 = DarkColorTokens.colorThemeBg,
bgSuccessSubtle = DarkColorTokens.colorGreen200,
borderAccentPrimary = DarkColorTokens.colorGreen900,
borderAccentSubtle = DarkColorTokens.colorGreen700,
borderCriticalHovered = DarkColorTokens.colorRed1000,
borderCriticalPrimary = DarkColorTokens.colorRed900,
@ -101,6 +102,7 @@ val compoundColorsDark = SemanticColors(
iconSuccessPrimary = DarkColorTokens.colorGreen900,
iconTertiary = DarkColorTokens.colorGray800,
iconTertiaryAlpha = DarkColorTokens.colorAlphaGray800,
separatorPrimary = DarkColorTokens.colorGray400,
textActionAccent = DarkColorTokens.colorGreen900,
textActionPrimary = DarkColorTokens.colorGray1400,
textBadgeAccent = DarkColorTokens.colorGreen1100,

View file

@ -61,6 +61,7 @@ val compoundColorsHcDark = SemanticColors(
bgSubtleSecondary = DarkHcColorTokens.colorGray300,
bgSubtleSecondaryLevel0 = DarkHcColorTokens.colorThemeBg,
bgSuccessSubtle = DarkHcColorTokens.colorGreen200,
borderAccentPrimary = DarkHcColorTokens.colorGreen900,
borderAccentSubtle = DarkHcColorTokens.colorGreen700,
borderCriticalHovered = DarkHcColorTokens.colorRed1000,
borderCriticalPrimary = DarkHcColorTokens.colorRed900,
@ -101,6 +102,7 @@ val compoundColorsHcDark = SemanticColors(
iconSuccessPrimary = DarkHcColorTokens.colorGreen900,
iconTertiary = DarkHcColorTokens.colorGray800,
iconTertiaryAlpha = DarkHcColorTokens.colorAlphaGray800,
separatorPrimary = DarkHcColorTokens.colorGray400,
textActionAccent = DarkHcColorTokens.colorGreen900,
textActionPrimary = DarkHcColorTokens.colorGray1400,
textBadgeAccent = DarkHcColorTokens.colorGreen1100,

View file

@ -61,6 +61,7 @@ val compoundColorsLight = SemanticColors(
bgSubtleSecondary = LightColorTokens.colorGray300,
bgSubtleSecondaryLevel0 = LightColorTokens.colorGray300,
bgSuccessSubtle = LightColorTokens.colorGreen200,
borderAccentPrimary = LightColorTokens.colorGreen900,
borderAccentSubtle = LightColorTokens.colorGreen700,
borderCriticalHovered = LightColorTokens.colorRed1000,
borderCriticalPrimary = LightColorTokens.colorRed900,
@ -101,6 +102,7 @@ val compoundColorsLight = SemanticColors(
iconSuccessPrimary = LightColorTokens.colorGreen900,
iconTertiary = LightColorTokens.colorGray800,
iconTertiaryAlpha = LightColorTokens.colorAlphaGray800,
separatorPrimary = LightColorTokens.colorGray400,
textActionAccent = LightColorTokens.colorGreen900,
textActionPrimary = LightColorTokens.colorGray1400,
textBadgeAccent = LightColorTokens.colorGreen1100,

View file

@ -61,6 +61,7 @@ val compoundColorsHcLight = SemanticColors(
bgSubtleSecondary = LightHcColorTokens.colorGray300,
bgSubtleSecondaryLevel0 = LightHcColorTokens.colorGray300,
bgSuccessSubtle = LightHcColorTokens.colorGreen200,
borderAccentPrimary = LightHcColorTokens.colorGreen900,
borderAccentSubtle = LightHcColorTokens.colorGreen700,
borderCriticalHovered = LightHcColorTokens.colorRed1000,
borderCriticalPrimary = LightHcColorTokens.colorRed900,
@ -101,6 +102,7 @@ val compoundColorsHcLight = SemanticColors(
iconSuccessPrimary = LightHcColorTokens.colorGreen900,
iconTertiary = LightHcColorTokens.colorGray800,
iconTertiaryAlpha = LightHcColorTokens.colorAlphaGray800,
separatorPrimary = LightHcColorTokens.colorGray400,
textActionAccent = LightHcColorTokens.colorGreen900,
textActionPrimary = LightHcColorTokens.colorGray1400,
textBadgeAccent = LightHcColorTokens.colorGreen1100,

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_date_at_time">"%1$s %2$s"</string>
<string name="common_date_this_month">"今月"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_date_at_time">"%1$s lúc %2$s"</string>
<string name="common_date_this_month">"Tháng này"</string>
</resources>

View file

@ -16,7 +16,10 @@ import android.widget.EditText
import androidx.appcompat.app.ActionBar.LayoutParams
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitVerticalPointerSlopOrCancellation
import androidx.compose.foundation.gestures.verticalDrag
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
@ -41,10 +44,14 @@ import androidx.compose.runtime.setValue
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.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
@ -94,7 +101,7 @@ fun ExpandableBottomSheetLayout(
.run {
if (isSwipeGestureEnabled) {
pointerInput(maxBottomSheetContentHeight) {
detectVerticalDragGestures(
customDetectVerticalDragGestures(
onVerticalDrag = { _, dragAmount ->
val calculatedHeight = max(minBottomContentHeightPx, currentBottomContentHeightPx - dragAmount.roundToInt())
val newHeight = min(calculatedMaxBottomContentHeightPx, calculatedHeight)
@ -120,7 +127,11 @@ fun ExpandableBottomSheetLayout(
animatable.animateTo(destination)
}
}
},
canScroll = {
// We only consider we can scroll in the contents if the min size matches the max size so it's maximized
minBottomContentHeightPx == calculatedMaxBottomContentHeightPx
},
)
}
} else {
@ -189,6 +200,45 @@ fun ExpandableBottomSheetLayout(
)
}
// The original detectVerticalDragGestures doesn't allow us to conditionally consume the initial slop event that triggers the drag,
// which is necessary in our case to allow inner scrollables to work when the sheet is not fully expanded, so we need to re-implement it here
private suspend fun PointerInputScope.customDetectVerticalDragGestures(
onDragStart: (Offset) -> Unit = {},
onDragEnd: () -> Unit = {},
onDragCancel: () -> Unit = {},
canScroll: () -> Boolean = { false },
onVerticalDrag: (change: PointerInputChange, dragAmount: Float) -> Unit,
) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
var overSlop = 0f
val drag =
awaitVerticalPointerSlopOrCancellation(down.id, down.type) { change, over ->
// Consuming this event is what triggers the dragging instead of the inner content scrolling
// We should only consume it if we can't scroll in the inner content so we drag the bottom sheet instead, otherwise we let it pass through
// This is the only change compared to the original detectVerticalDragGestures implementation
if (!canScroll()) {
change.consume()
}
overSlop = over
}
if (drag != null) {
onDragStart.invoke(drag.position)
onVerticalDrag.invoke(drag, overSlop)
if (
verticalDrag(drag.id) {
onVerticalDrag(it, it.positionChange().y)
it.consume()
}
) {
onDragEnd()
} else {
onDragCancel()
}
}
}
}
@Preview(showBackground = true)
@Composable
@Suppress("UnusedPrivateMember")

View file

@ -32,9 +32,13 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.withSave
import coil3.Image
import coil3.ImageLoader
@ -50,6 +54,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables
private val PIN_WIDTH = 42.dp
private val PIN_HEIGHT = PIN_WIDTH * 1.2f
@ -99,21 +104,33 @@ fun LocationPin(
fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? {
val context = LocalContext.current
val density = LocalDensity.current
val imageLoader = SingletonImageLoader.get(context)
val colors = pinColors(variant)
val cacheKey = rememberCacheKey(variant)
return produceState<ImageBitmap?>(initialValue = null, cacheKey) {
val memoryCacheKey = MemoryCache.Key(cacheKey)
val cached = imageLoader.memoryCache?.get(memoryCacheKey)
if (cached != null) {
value = cached.image.toBitmap().asImageBitmap()
} else {
val dimensions = PinDimensions(density)
val bitmap = LocationPinRenderer.renderPin(variant, colors, dimensions, context, imageLoader)
imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
value = bitmap.asImageBitmap()
}
}.value
val resources = LocalResources.current
return if (LocalInspectionMode.current) {
// In preview mode, skip async loading and return a simple placeholder image instead to avoid using ImageLoader
val dimensions = PinDimensions(density)
val avatarImage = ResourcesCompat.getDrawable(resources, CommonDrawables.sample_avatar, context.theme)?.toBitmap()?.asImage()
LocationPinRenderer.renderPin(variant, colors, dimensions, avatarImage).asImageBitmap()
} else {
produceState<ImageBitmap?>(initialValue = null, cacheKey) {
val imageLoader = SingletonImageLoader.get(context)
val memoryCacheKey = MemoryCache.Key(cacheKey)
val cached = imageLoader.memoryCache?.get(memoryCacheKey)
if (cached != null) {
value = cached.image.toBitmap().asImageBitmap()
} else {
val dimensions = PinDimensions(density)
val bitmap = with(LocationPinRenderer) {
val avatarImage = loadAvatarImage(variant, context, imageLoader)
renderPin(variant, colors, dimensions, avatarImage)
}
imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
value = bitmap.asImageBitmap()
}
}.value
}
}
@Composable
@ -208,19 +225,17 @@ private object LocationPinRenderer {
/**
* Renders a pin variant to bitmap. Suspending for async avatar loading.
*/
suspend fun renderPin(
fun renderPin(
variant: PinVariant,
colors: PinColors,
dimensions: PinDimensions,
context: Context,
imageLoader: ImageLoader,
avatarImage: Image?,
): Bitmap {
val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt())
val canvas = Canvas(bitmap)
canvas.drawPinShape(colors.fill, colors.stroke, dimensions)
when (variant) {
is PinVariant.UserLocation -> {
val avatarImage = loadAvatarImage(variant.avatarData, context, imageLoader)
canvas.drawAvatar(
avatarImage = avatarImage,
avatarData = variant.avatarData,
@ -284,11 +299,15 @@ private object LocationPinRenderer {
return path
}
private suspend fun loadAvatarImage(
avatarData: AvatarData,
suspend fun loadAvatarImage(
variant: PinVariant,
context: Context,
imageLoader: ImageLoader,
): Image? {
val avatarData = when (variant) {
is PinVariant.UserLocation -> variant.avatarData
else -> return null
}
val request = ImageRequest.Builder(context)
.data(avatarData)
// Disable hardware rendering for Canvas

View file

@ -13,10 +13,12 @@ import androidx.compose.ui.unit.dp
enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(32.dp),
CurrentRoomTopBar(32.dp),
IncomingCall(140.dp),
RoomDetailsHeader(96.dp),
RoomListItem(52.dp),
ThreadsListItem(52.dp),
SpaceListItem(52.dp),

View file

@ -0,0 +1,79 @@
/*
* 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.avatar
import android.graphics.Bitmap
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import io.element.android.libraries.designsystem.components.avatar.internal.InitialLetterAvatar
import timber.log.Timber
// For user avatar only.
@Composable
fun BitmapAvatar(
avatarData: AvatarData,
bitmap: Bitmap?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val avatarShape = AvatarType.User.avatarShape()
when {
bitmap == null -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
modifier = modifier,
contentDescription = contentDescription,
)
else -> {
val size = avatarData.size.dp
SubcomposeAsyncImage(
model = bitmap,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = modifier
.size(size)
.clip(avatarShape)
) {
val collectedState by painter.state.collectAsState()
when (val state = collectedState) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Error -> {
SideEffect {
Timber.e(
state.result.throwable,
"Error loading avatar $state\n${state.result}"
)
}
InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
contentDescription = contentDescription,
)
}
else -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
contentDescription = contentDescription,
)
}
}
}
}
}

View file

@ -75,6 +75,9 @@ val SemanticColors.pinnedMessageBannerIndicator
val SemanticColors.pinnedMessageBannerBorder
get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400
val SemanticColors.floatingDateBadgeBackground
get() = if (isLight) bgCanvasDefault else bgSubtlePrimary
@PreviewsDayNight
@Composable
internal fun ColorAliasesPreview() = ElementPreview {

View file

@ -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.designsystem.utils
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun hasCompactHeightWindowSize(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact
}

View file

@ -118,15 +118,6 @@ class StateContentFormatter(
"PolicyRuleUser"
}
}
OtherState.RoomAliases -> when (renderingMode) {
RenderingMode.RoomList -> {
Timber.v("Filtering timeline item for room state change: $content")
null
}
RenderingMode.Timeline -> {
"RoomAliases"
}
}
OtherState.RoomCanonicalAlias -> when (renderingMode) {
RenderingMode.RoomList -> {
Timber.v("Filtering timeline item for room state change: $content")

View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="state_event_avatar_changed_too">"(アバターも変更)"</string>
<string name="state_event_avatar_url_changed">"%1$s がアバターを変更"</string>
<string name="state_event_avatar_url_changed_by_you">"あなたがアバターを変更"</string>
<string name="state_event_demoted_to_member">"%1$s がメンバーに降格"</string>
<string name="state_event_demoted_to_moderator">"%1$s がモデレーターに降格"</string>
<string name="state_event_display_name_changed_from">"%1$sが表示名を変更: %2$s &gt; %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"あなたが表示名を変更: %1$s &gt; %2$s"</string>
<string name="state_event_display_name_removed">"%1$sが表示名を削除 (%2$s)"</string>
<string name="state_event_display_name_removed_by_you">"表示名を削除 (%1$s)"</string>
<string name="state_event_display_name_set">"%1$sが表示名を設定: %2$s"</string>
<string name="state_event_display_name_set_by_you">"あなたが表示名を設定: %1$s"</string>
<string name="state_event_promoted_to_administrator">"%1$s が管理者に昇格"</string>
<string name="state_event_promoted_to_moderator">"%1$s がモデレーターに昇格"</string>
<string name="state_event_room_avatar_changed">"%1$sがルームアバターを変更"</string>
<string name="state_event_room_avatar_changed_by_you">"あなたがルームアバターを変更"</string>
<string name="state_event_room_avatar_removed">"%1$sがルームアバターを削除"</string>
<string name="state_event_room_avatar_removed_by_you">"あなたがルームアバターを削除"</string>
<string name="state_event_room_ban">"%1$s が %2$s を追放"</string>
<string name="state_event_room_ban_by_you">"あなたが %1$s を追放"</string>
<string name="state_event_room_ban_by_you_with_reason">"あなたが %1$s を追放: %2$s"</string>
<string name="state_event_room_ban_with_reason">"%1$s が %2$s を追放: %3$s"</string>
<string name="state_event_room_created">"%1$s がルームを作成"</string>
<string name="state_event_room_created_by_you">"あなたがルームを作成"</string>
<string name="state_event_room_invite">"%1$s が %2$s を招待"</string>
<string name="state_event_room_invite_accepted">"%1$s が招待を受諾"</string>
<string name="state_event_room_invite_accepted_by_you">"あなたが招待を受諾"</string>
<string name="state_event_room_invite_by_you">"あなたが %1$s を招待"</string>
<string name="state_event_room_invite_you">"%1$s があなたを招待"</string>
<string name="state_event_room_join">"%1$s がルームに参加"</string>
<string name="state_event_room_join_by_you">"あなたがルームに参加"</string>
<string name="state_event_room_knock">"%1$s が参加をリクエスト"</string>
<string name="state_event_room_knock_accepted">"%1$s が %2$s の参加を許可"</string>
<string name="state_event_room_knock_accepted_by_you">"あなたが %1$s の参加を許可"</string>
<string name="state_event_room_knock_by_you">"あなたが参加をリクエスト"</string>
<string name="state_event_room_knock_denied">"%1$s が %2$s の参加リクエストを拒否"</string>
<string name="state_event_room_knock_denied_by_you">"あなたが %1$s の参加リクエストを拒否"</string>
<string name="state_event_room_knock_denied_you">"%1$s があなたの参加リクエストを拒否"</string>
<string name="state_event_room_knock_retracted">"%1$s が参加リクエストを取り消し"</string>
<string name="state_event_room_knock_retracted_by_you">"あなたが参加リクエストを取り消し"</string>
<string name="state_event_room_leave">"%1$s がルームを退出"</string>
<string name="state_event_room_leave_by_you">"あなたがルームを退出"</string>
<string name="state_event_room_name_changed">"%1$s がルーム名を変更: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"あなたがルーム名を変更: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s がルーム名を削除"</string>
<string name="state_event_room_name_removed_by_you">"あなたがルーム名を削除"</string>
<string name="state_event_room_none">"%1$s による変更はありません"</string>
<string name="state_event_room_none_by_you">"あなたによる変更はありません"</string>
<string name="state_event_room_pinned_events_changed">"%1$s はピン留めメッセージを変更しました"</string>
<string name="state_event_room_pinned_events_changed_by_you">"あなたがピン留めメッセージを変更しました"</string>
<string name="state_event_room_pinned_events_pinned">"%1$s がメッセージをピン留め"</string>
<string name="state_event_room_pinned_events_pinned_by_you">"あなたがメッセージをピン留め"</string>
<string name="state_event_room_pinned_events_unpinned">"%1$s がメッセージのピン留めを解除"</string>
<string name="state_event_room_pinned_events_unpinned_by_you">"あなたがメッセージのピン留めを解除"</string>
<string name="state_event_room_reject">"%1$s が招待を拒否"</string>
<string name="state_event_room_reject_by_you">"あなたが招待を拒否"</string>
<string name="state_event_room_remove">"%1$s が %2$s を削除"</string>
<string name="state_event_room_remove_by_you">"あなたが %1$s を削除"</string>
<string name="state_event_room_remove_by_you_with_reason">"あなたが%1$s を削除: %2$s"</string>
<string name="state_event_room_remove_with_reason">"%1$s が %2$s を削除: %3$s"</string>
<string name="state_event_room_third_party_invite">"%1$s が %2$s をルームに招待"</string>
<string name="state_event_room_third_party_invite_by_you">"あなたが %1$s をルームに招待"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s が %2$s へのルームの招待を取り消し"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"あなたが %1$s へのルームの招待を取り消し"</string>
<string name="state_event_room_topic_changed">"%1$s がトピックを変更: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"あなたがトピックを変更: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s がルームのトピックを削除"</string>
<string name="state_event_room_topic_removed_by_you">"あなたがルームのトピックを削除"</string>
<string name="state_event_room_unban">"%1$s が %2$s の追放を解除"</string>
<string name="state_event_room_unban_by_you">"あなたが %1$s の追放を解除"</string>
<string name="state_event_room_unknown_membership_change">"%1$s がメンバーシップに未知の変更を追加"</string>
</resources>

View file

@ -1,13 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="state_event_avatar_changed_too">"(taip pat buvo pakeistas ir avataras)"</string>
<string name="state_event_avatar_url_changed">"%1$s pakeitė savo avatarą"</string>
<string name="state_event_avatar_url_changed_by_you">"Jūs pakeitėte savo avatarą"</string>
<string name="state_event_avatar_url_changed">"%1$s pakeitė savo pseudoportretą"</string>
<string name="state_event_avatar_url_changed_by_you">"Jūs pakeitėte savo pseudoportretą"</string>
<string name="state_event_display_name_changed_from">"%1$s pakeitė savo slapyvardį iš %2$s į %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Jūs pakeitėte savo slapyvardį iš %1$s į %2$s"</string>
<string name="state_event_display_name_removed">"%1$s pašalino savo slapyvardį (jis buvo %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Jūs pašalinote savo slapyvardį (jis buvo %1$s)"</string>
<string name="state_event_display_name_set">"%1$s pakeitė savo slapyvardį į %2$s"</string>
<string name="state_event_display_name_set">"%1$s nustatė savo rodomą vardą į %2$s"</string>
<string name="state_event_display_name_set_by_you">"Jūs nustatėte savo slapyvardį į %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s pakeitė kambario avatarą"</string>
<string name="state_event_room_avatar_changed_by_you">"Jūs pakeitėte kambario avatarą"</string>
@ -21,7 +21,7 @@
<string name="state_event_room_invite_accepted">"%1$s priėmė kvietimą"</string>
<string name="state_event_room_invite_accepted_by_you">"Priėmėte kvietimą"</string>
<string name="state_event_room_invite_by_you">"Jūs pakvietėte %1$s"</string>
<string name="state_event_room_invite_you">"%1$s pakvietė Jus"</string>
<string name="state_event_room_invite_you">"%1$s pakvietė jus"</string>
<string name="state_event_room_join">"%1$s prisijungė prie kambario"</string>
<string name="state_event_room_join_by_you">"Jūs prisijungėte prie kambario"</string>
<string name="state_event_room_knock">"%1$s prašo prisijungti"</string>

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="state_event_avatar_changed_too">"(ảnh hồ sơ cũng được thay)"</string>
<string name="state_event_avatar_url_changed">"%1$s đổi ảnh hồ sơ"</string>
<string name="state_event_avatar_url_changed_by_you">"Bạn đổi ảnh hồ sơ"</string>
<string name="state_event_demoted_to_member">"%1$s bị giáng cấp xuống thành thành viên"</string>
<string name="state_event_demoted_to_moderator">"%1$s bị giáng chức xuống làm người điều hành"</string>
<string name="state_event_display_name_changed_from">"%1$s đổi tên hiển thị từ %2$s sang %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Bạn đổi tên hiển thị từ %1$s sang %2$s"</string>
<string name="state_event_display_name_removed">"%1$s xoá tên hiển thị (trước kia là %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Bạn xoá tên hiển thị (trước kia là %1$s)"</string>
<string name="state_event_display_name_set">"%1$s đặt tên hiển thị thành %2$s"</string>
<string name="state_event_display_name_set_by_you">"Bạn đặt tên hiển thị thành %1$s"</string>
<string name="state_event_promoted_to_administrator">"%1$s đã được thăng chức lên quản trị viên"</string>
<string name="state_event_promoted_to_moderator">"%1$s đã được thăng chức lên làm người điều hành"</string>
<string name="state_event_room_avatar_changed">"%1$s đổi ảnh phòng"</string>
<string name="state_event_room_avatar_changed_by_you">"Bạn đổi ảnh phòng"</string>
<string name="state_event_room_avatar_removed">"%1$s đã xóa ảnh đại diện của phòng."</string>
<string name="state_event_room_avatar_removed_by_you">"Bạn đã xóa hình đại diện của phòng trò chuyện"</string>
<string name="state_event_room_ban">"%1$s cấm %2$s vào phòng"</string>
<string name="state_event_room_ban_by_you">"Bạn cấm %1$s vào phòng"</string>
<string name="state_event_room_created">"%1$s tạo phòng này"</string>
<string name="state_event_room_created_by_you">"Bạn tạo phòng này"</string>
<string name="state_event_room_invite">"%1$s mời %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s đã chấp nhận lời mời"</string>
<string name="state_event_room_invite_accepted_by_you">"Bạn đã chấp nhận lời mời"</string>
<string name="state_event_room_invite_by_you">"Bạn mời %1$s"</string>
<string name="state_event_room_invite_you">"%1$s mời bạn"</string>
<string name="state_event_room_join">"%1$s vào phòng"</string>
<string name="state_event_room_join_by_you">"Bạn vào phòng"</string>
<string name="state_event_room_knock">"%1$s đang yêu cầu tham gia"</string>
<string name="state_event_room_knock_accepted">"%1$s được cấp quyền truy cập vào %2$s"</string>
<string name="state_event_room_knock_accepted_by_you">"Bạn đã cho phép %1$s tham gia"</string>
<string name="state_event_room_knock_by_you">"Bạn đã yêu cầu tham gia"</string>
<string name="state_event_room_knock_denied">"%1$s đã từ chối yêu cầu tham gia của %2$s"</string>
<string name="state_event_room_knock_denied_by_you">"Bạn đã từ chối yêu cầu tham gia của %1$s"</string>
<string name="state_event_room_knock_denied_you">"%1$s đã từ chối yêu cầu tham gia của bạn"</string>
<string name="state_event_room_knock_retracted">"%1$s không còn mong muốn tham gia"</string>
<string name="state_event_room_knock_retracted_by_you">"Bạn đã hủy yêu cầu tham gia"</string>
<string name="state_event_room_leave">"%1$s rời phòng"</string>
<string name="state_event_room_leave_by_you">"Bạn rời phòng"</string>
<string name="state_event_room_name_changed">"%1$s đổi tên phòng thành %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Bạn đổi tên phòng thành %1$s"</string>
<string name="state_event_room_name_removed">"%1$s xóa tên phòng"</string>
<string name="state_event_room_name_removed_by_you">"Bạn xóa tên phòng"</string>
<string name="state_event_room_none">"%1$s không có thay đổi nào"</string>
<string name="state_event_room_none_by_you">"Bạn chưa thực hiện thay đổi nào"</string>
<string name="state_event_room_pinned_events_changed_by_you">"Bạn đã thay đổi tin nhắn được ghim"</string>
<string name="state_event_room_reject">"%1$s từ chối lời mời"</string>
<string name="state_event_room_reject_by_you">"Bạn từ chối lời mời"</string>
<string name="state_event_room_remove">"%1$s cho %2$s cút khỏi phòng"</string>
<string name="state_event_room_remove_by_you">"Bạn cho %1$s cút khỏi phòng"</string>
<string name="state_event_room_third_party_invite">"%1$s đã gửi lời mời đến %2$s để tham gia phòng trò chuyện"</string>
<string name="state_event_room_third_party_invite_by_you">"Bạn đã gửi lời mời đến %1$s để tham gia phòng trò chuyện"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s đã thu hồi lời mời tham gia phòng trò chuyện của %2$s "</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Bạn đã thu hồi lời mời tham gia phòng trò chuyện của %1$s "</string>
<string name="state_event_room_topic_changed">"%1$s đổi chủ đề sang: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Bạn đổi chủ đề sang: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s đã xóa chủ đề phòng"</string>
<string name="state_event_room_topic_removed_by_you">"Bạn đã xóa chủ đề của phòng."</string>
<string name="state_event_room_unban">"%1$s hủy lệnh cấm với %2$s"</string>
<string name="state_event_room_unban_by_you">"Bạn hủy lệnh cấm với %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s đã thực hiện một thay đổi không xác định đối với tư cách thành viên của họ"</string>
</resources>

View file

@ -54,11 +54,11 @@
<string name="state_event_room_pinned_events_unpinned">"%1$s 取消置顶了一条消息"</string>
<string name="state_event_room_pinned_events_unpinned_by_you">"您取消置顶了一条消息"</string>
<string name="state_event_room_reject">"%1$s 拒绝了邀请"</string>
<string name="state_event_room_reject_by_you">"拒绝了邀请"</string>
<string name="state_event_room_remove">"%1$s 移除 %2$s"</string>
<string name="state_event_room_remove_by_you">"移除了 %1$s"</string>
<string name="state_event_room_remove_by_you_with_reason">"您已删除%1$s %2$s"</string>
<string name="state_event_room_remove_with_reason">"%1$s已移除%2$s%3$s"</string>
<string name="state_event_room_reject_by_you">"拒绝了邀请"</string>
<string name="state_event_room_remove">"%1$s 移除 %2$s"</string>
<string name="state_event_room_remove_by_you">"移除了 %1$s"</string>
<string name="state_event_room_remove_by_you_with_reason">"您移除了 %1$s%2$s"</string>
<string name="state_event_room_remove_with_reason">"%1$s 移除了 %2$s%3$s"</string>
<string name="state_event_room_third_party_invite">"%1$s 向 %2$s 发送了加入聊天室的邀请"</string>
<string name="state_event_room_third_party_invite_by_you">"你邀请 %1$s 加入聊天室"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s 撤销了 %2$s 加入聊天室的邀请"</string>

View file

@ -601,7 +601,6 @@ class DefaultPinnedMessagesBannerFormatterTest {
OtherState.PolicyRuleRoom,
OtherState.PolicyRuleServer,
OtherState.PolicyRuleUser,
OtherState.RoomAliases,
OtherState.RoomCanonicalAlias,
OtherState.RoomGuestAccess,
OtherState.RoomHistoryVisibility,

View file

@ -746,7 +746,6 @@ class DefaultRoomLatestEventFormatterTest {
OtherState.PolicyRuleRoom,
OtherState.PolicyRuleServer,
OtherState.PolicyRuleUser,
OtherState.RoomAliases,
OtherState.RoomCanonicalAlias,
OtherState.RoomGuestAccess,
OtherState.RoomHistoryVisibility,

View file

@ -70,27 +70,6 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
CreateSpaces(
key = "feature.createSpaces",
title = "Create spaces",
description = "Allow creating spaces.",
defaultValue = { true },
isFinished = false,
),
SpaceSettings(
key = "feature.spaceSettings",
title = "Space settings",
description = "Allow managing space settings such as details, permissions and privacy.",
defaultValue = { true },
isFinished = false,
),
RoomListSpaceFilters(
key = "feature.roomListSpaceFilters",
title = "Room list space filters",
description = "Allow filtering the room list by space.",
defaultValue = { true },
isFinished = false,
),
PrintLogsToLogcat(
key = "feature.print_logs_to_logcat",
title = "Print logs to logcat",
@ -156,10 +135,31 @@ enum class FeatureFlags(
),
ValidateNetworkWhenSchedulingNotificationFetching(
key = "feature.validate_network_when_scheduling_notification_fetching",
title = "validate internet connectivity when scheduling notification fetching",
title = "Validate internet connectivity when scheduling notification fetching",
description = "Only fetch events for push notifications when the device has internet connectivity. " +
"Enabling this can be problematic in air-gapped environments.",
defaultValue = { true },
isFinished = false,
),
FloatingDateBadge(
key = "feature.floating_date_badge",
title = "Display sticky date headers in the timeline",
description = "When scrolling, a sticky date badge will be displayed so you can easily know on which date the messages you're seeing were sent.",
defaultValue = { false },
isFinished = false,
),
SlashCommand(
key = "feature.slash_command",
title = "Parse slash commands in the message composer",
description = "Allow parsing slash commands in the message composer and perform action.",
defaultValue = { false },
isFinished = false,
),
RoomThreadList(
key = "feature.room_thread_list",
title = "Add a list of threads in a room",
description = "Add a new screen with a list of threads in a room.",
defaultValue = { false },
isFinished = false,
),
}

View file

@ -0,0 +1,30 @@
/*
* 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
/**
* Provides information about the capabilities of the homeserver.
*
* Spec: https://spec.matrix.org/latest/client-server-api/#capabilities-negotiation
*/
interface HomeserverCapabilitiesProvider {
/**
* Manually refresh the capabilities of the homeserver performing a network request.
*/
suspend fun refresh(): Result<Unit>
/**
* Indicates whether the homeserver allows the user to change their display name.
*/
suspend fun canChangeDisplayName(): Result<Boolean>
/**
* Indicates whether the homeserver allows the user to change their avatar URL.
*/
suspend fun canChangeAvatarUrl(): Result<Boolean>
}

View file

@ -225,6 +225,8 @@ interface MatrixClient {
* Resets the cached client `well-known` config by the SDK.
*/
suspend fun resetWellKnownConfig(): Result<Unit>
fun homeserverCapabilities(): HomeserverCapabilitiesProvider
}
/**

View file

@ -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.
*/
package io.element.android.libraries.matrix.api.auth
import io.element.android.libraries.matrix.api.core.UserId
data class ElementClassicSession(
val userId: UserId,
val homeserverUrl: String?,
val secrets: String?,
val roomKeysVersion: String?,
val doesContainBackupKey: Boolean,
)

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
interface MatrixAuthenticationService {
/**
@ -52,6 +53,20 @@ interface MatrixAuthenticationService {
*/
suspend fun cancelOidcLogin(): Result<Unit>
/**
* Set the existing data about Element Classic session, if any.
*/
fun setElementClassicSession(session: ElementClassicSession?)
/**
* Check if the provided secrets from Element Classic session contain a key backup.
*/
fun doSecretsContainBackupKey(
userId: UserId,
secrets: String,
backupInfo: String,
): Boolean
/**
* Attempt to login using the [callbackUrl] provided by the Oidc page.
*/

View file

@ -27,6 +27,16 @@ sealed interface RoomIdOrAlias : Parcelable {
is Id -> roomId.value
is Alias -> roomAlias.value
}
companion object {
fun from(id: String): RoomIdOrAlias? {
return when {
MatrixPatterns.isRoomId(id) -> Id(RoomId(id))
MatrixPatterns.isRoomAlias(id) -> Alias(RoomAlias(id))
else -> null
}
}
}
}
fun RoomId.toRoomIdOrAlias() = RoomIdOrAlias.Id(this)

View file

@ -17,3 +17,18 @@ interface MxcTools {
*/
fun mxcUri2FilePath(mxcUri: String): String?
}
/**
* "mxc" scheme, including "://". So "mxc://".
*/
const val MATRIX_CONTENT_URI_SCHEME = "mxc://"
/**
* Return true if the String starts with "mxc://".
*/
fun String.isMxcUrl() = startsWith(MATRIX_CONTENT_URI_SCHEME)
/**
* Remove the "mxc://" prefix. No op if the String is not a Mxc URL.
*/
fun String.removeMxcPrefix() = removePrefix(MATRIX_CONTENT_URI_SCHEME)

View file

@ -95,7 +95,6 @@ sealed interface NotificationContent {
data object PolicyRuleRoom : StateEvent
data object PolicyRuleServer : StateEvent
data object PolicyRuleUser : StateEvent
data object RoomAliases : StateEvent
data object RoomAvatar : StateEvent
data object RoomCanonicalAlias : StateEvent
data object RoomCreate : StateEvent

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
@ -44,6 +45,8 @@ interface JoinedRoom : BaseRoom {
*/
val liveTimeline: Timeline
val threadsListService: ThreadsListService
/**
* Create a new timeline.
* @param createTimelineParams contains parameters about how to filter the timeline. Will also configure the date separators.

View file

@ -13,7 +13,6 @@ sealed interface StateEventType {
data object PolicyRuleServer : StateEventType
data object PolicyRuleUser : StateEventType
data object CallMember : StateEventType
data object RoomAliases : StateEventType
data object RoomAvatar : StateEventType
data object RoomCanonicalAlias : StateEventType
data object RoomCreate : StateEventType

View file

@ -0,0 +1,34 @@
/*
* 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.room.threads
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toThreadId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
@Immutable
data class ThreadListItem(
val rootEvent: ThreadListItemEvent,
val latestEvent: ThreadListItemEvent?,
val numberOfReplies: Long,
) {
val threadId = rootEvent.eventId.toThreadId()
}
@Immutable
data class ThreadListItemEvent(
val eventId: EventId,
val senderId: UserId,
val senderProfile: ProfileDetails,
val isOwn: Boolean,
val content: EventContent?,
val timestamp: Long,
)

View file

@ -0,0 +1,16 @@
/*
* 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.room.threads
sealed interface ThreadListPaginationStatus {
data class Idle(
val hasMoreToLoad: Boolean,
) : ThreadListPaginationStatus
data object Loading : ThreadListPaginationStatus
}

View file

@ -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.
*/
package io.element.android.libraries.matrix.api.room.threads
import kotlinx.coroutines.flow.Flow
interface ThreadsListService {
fun subscribeToItemUpdates(): Flow<List<ThreadListItem>>
fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus>
suspend fun paginate(): Result<Unit>
suspend fun reset(): Result<Unit>
fun destroy()
}

View file

@ -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.matrix.api.timeline
enum class MsgType {
MSG_TYPE_TEXT,
MSG_TYPE_EMOTE,
// For future support
MSG_TYPE_SNOW,
// For future support
MSG_TYPE_CONFETTI,
}

View file

@ -69,6 +69,8 @@ interface Timeline : AutoCloseable {
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
asPlainText: Boolean = false,
): Result<Unit>
/**
@ -102,6 +104,7 @@ interface Timeline : AutoCloseable {
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean = false,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
): Result<Unit>
suspend fun sendImage(

View file

@ -16,7 +16,6 @@ sealed interface OtherState {
data object PolicyRuleRoom : OtherState
data object PolicyRuleServer : OtherState
data object PolicyRuleUser : OtherState
data object RoomAliases : OtherState
data class RoomAvatar(val url: String?) : OtherState
data object RoomCanonicalAlias : OtherState
data object RoomCreate : OtherState

View file

@ -0,0 +1,20 @@
/*
* 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.core
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class UserIdTest {
@Test
fun `valid user id`() {
val userId = UserId("@alice:example.org")
assertThat(userId.extractedDisplayName).isEqualTo("alice")
assertThat(userId.domainName).isEqualTo("example.org")
}
}

View file

@ -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.impl
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
class RustHomeserverCapabilitiesProvider(
private val homeserverCapabilities: HomeserverCapabilities,
) : HomeserverCapabilitiesProvider {
override suspend fun refresh(): Result<Unit> = runCatchingExceptions {
homeserverCapabilities.refresh()
}
override suspend fun canChangeDisplayName(): Result<Boolean> = runCatchingExceptions {
homeserverCapabilities.canChangeDisplayname()
}
override suspend fun canChangeAvatarUrl(): Result<Boolean> = runCatchingExceptions {
homeserverCapabilities.canChangeAvatar()
}
}

View file

@ -17,6 +17,7 @@ import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.DeviceId
@ -838,6 +839,10 @@ class RustMatrixClient(
val request = PerformDatabaseVacuumRequestBuilder(sessionId)
sessionCoroutineScope.launch { workManagerScheduler.submit(request) }
}
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities())
}
}
private fun defaultRoomCreationPowerLevels(isPublic: Boolean, isSpace: Boolean) = PowerLevels(

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.AuthenticationException
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
@ -50,6 +52,7 @@ import org.matrix.rustcomponents.sdk.QrCodeData
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
import org.matrix.rustcomponents.sdk.QrLoginProgress
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
import org.matrix.rustcomponents.sdk.SecretsBundleWithUserId
import timber.log.Timber
import uniffi.matrix_sdk.OAuthAuthorizationData
import kotlin.time.Duration.Companion.seconds
@ -64,6 +67,9 @@ class RustMatrixAuthenticationService(
private val passphraseGenerator: PassphraseGenerator,
private val oidcConfigurationProvider: OidcConfigurationProvider,
) : MatrixAuthenticationService {
// Any existing Element Classic session that we want to try to import secrets from during login.
private var elementClassicSession: ElementClassicSession? = null
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
// stored in the SessionData.
private val pendingPassphrase = getDatabasePassphrase()
@ -138,9 +144,15 @@ class RustMatrixAuthenticationService(
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.login(username, password, "Element X Android", null)
client.login(
username = username,
password = password,
initialDeviceName = "Element X Android",
deviceId = null,
)
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
tryToImportSecretForElementClassicSession(client)
val sessionData = client.session()
.toSessionData(
isTokenValid = true,
@ -162,6 +174,53 @@ class RustMatrixAuthenticationService(
}
}
private suspend fun tryToImportSecretForElementClassicSession(client: Client) {
elementClassicSession
?.takeIf {
// Note: the SDK will also do this check
it.userId.value == client.userId()
}
?.let {
val secrets = it.secrets
val roomKeysVersion = it.roomKeysVersion
if (secrets == null || roomKeysVersion == null) {
Timber.d("No secrets or roomKeysVersion found for Element Classic session ${it.userId}, skipping import")
} else {
Timber.d("Trying to import secrets for Element Classic session ${it.userId}")
runCatchingExceptions {
SecretsBundleWithUserId.fromStr(
userId = it.userId.value,
bundle = secrets,
backupInfo = roomKeysVersion,
).use { secretsBundle ->
client.encryption().importSecretsBundle(secretsBundle)
}
}.onFailure { failure ->
Timber.e(failure, "Failed to import secrets for Element Classic session ${it.userId}")
}
}
}
}
override fun doSecretsContainBackupKey(
userId: UserId,
secrets: String,
backupInfo: String,
): Boolean {
return try {
SecretsBundleWithUserId.fromStr(
userId = userId.value,
bundle = secrets,
backupInfo = backupInfo,
).use { secretsBundle ->
secretsBundle.containsBackupKey()
}
} catch (failure: Exception) {
Timber.e(failure, "Failed to parse secrets for Element Classic session $userId")
false
}
}
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> =
withContext(coroutineDispatchers.io) {
runCatchingExceptions {
@ -233,6 +292,10 @@ class RustMatrixAuthenticationService(
}
}
override fun setElementClassicSession(session: ElementClassicSession?) {
elementClassicSession = session
}
/**
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
*/
@ -241,14 +304,15 @@ class RustMatrixAuthenticationService(
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.loginWithOidcCallback(callbackUrl)
client.loginWithOidcCallback(
callbackUrl = callbackUrl,
)
// Free the pending data since we won't use it to abort the flow anymore
pendingOAuthAuthorizationData?.close()
pendingOAuthAuthorizationData = null
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
tryToImportSecretForElementClassicSession(client)
val sessionData = client.session().toSessionData(
isTokenValid = true,
loginType = LoginType.OIDC,

View file

@ -13,6 +13,7 @@ import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@ -90,4 +91,9 @@ object SessionMatrixModule {
fun providesSpaceService(matrixClient: MatrixClient): SpaceService {
return matrixClient.spaceService
}
@Provides
fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider {
return matrixClient.homeserverCapabilities()
}
}

View file

@ -49,7 +49,6 @@ private fun StateEventContent.toContent(): NotificationContent.StateEvent {
StateEventContent.PolicyRuleRoom -> NotificationContent.StateEvent.PolicyRuleRoom
StateEventContent.PolicyRuleServer -> NotificationContent.StateEvent.PolicyRuleServer
StateEventContent.PolicyRuleUser -> NotificationContent.StateEvent.PolicyRuleUser
StateEventContent.RoomAliases -> NotificationContent.StateEvent.RoomAliases
StateEventContent.RoomAvatar -> NotificationContent.StateEvent.RoomAvatar
StateEventContent.RoomCanonicalAlias -> NotificationContent.StateEvent.RoomCanonicalAlias
StateEventContent.RoomCreate -> NotificationContent.StateEvent.RoomCreate

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
@ -43,10 +44,11 @@ import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
import io.element.android.libraries.matrix.impl.room.location.map
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService
import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.util.MessageEventContent
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
@ -68,7 +70,6 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
import org.matrix.rustcomponents.sdk.KnockRequestsListener
import org.matrix.rustcomponents.sdk.LiveLocationShareListener
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
import org.matrix.rustcomponents.sdk.SendQueueListener
@ -147,6 +148,12 @@ class JoinedRustRoom(
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live)
override val threadsListService: ThreadsListService = RustThreadsListService(
inner = innerRoom.threadListService(),
contentMapper = TimelineEventContentMapper(),
roomCoroutineScope = roomCoroutineScope,
)
override val syncUpdateFlow = flow {
var counter = 0L
liveTimeline.onSyncedEventReceived.collect {
@ -504,13 +511,7 @@ class JoinedRustRoom(
}
override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> {
return mxCallbackFlow {
innerRoom.subscribeToLiveLocationShares(object : LiveLocationShareListener {
override fun call(liveLocationShares: List<org.matrix.rustcomponents.sdk.LiveLocationShare>) {
trySend(liveLocationShares.map { it.map() })
}
})
}
TODO("Not implemented yet")
}
override suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit> = withContext(roomDispatcher) {
@ -536,6 +537,7 @@ class JoinedRustRoom(
override fun destroy() {
baseRoom.destroy()
liveInnerTimeline.destroy()
threadsListService.destroy()
Timber.d("Room $roomId destroyed")
}

View file

@ -16,7 +16,6 @@ fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.PolicyRuleServer -> RustStateEventType.PolicyRuleServer
StateEventType.PolicyRuleUser -> RustStateEventType.PolicyRuleUser
StateEventType.CallMember -> RustStateEventType.CallMember
StateEventType.RoomAliases -> RustStateEventType.RoomAliases
StateEventType.RoomAvatar -> RustStateEventType.RoomAvatar
StateEventType.RoomCanonicalAlias -> RustStateEventType.RoomCanonicalAlias
StateEventType.RoomCreate -> RustStateEventType.RoomCreate
@ -46,7 +45,6 @@ fun RustStateEventType.map(): StateEventType = when (this) {
RustStateEventType.PolicyRuleServer -> StateEventType.PolicyRuleServer
RustStateEventType.PolicyRuleUser -> StateEventType.PolicyRuleUser
RustStateEventType.CallMember -> StateEventType.CallMember
RustStateEventType.RoomAliases -> StateEventType.RoomAliases
RustStateEventType.RoomAvatar -> StateEventType.RoomAvatar
RustStateEventType.RoomCanonicalAlias -> StateEventType.RoomCanonicalAlias
RustStateEventType.RoomCreate -> StateEventType.RoomCreate

View file

@ -1,21 +0,0 @@
/*
* Copyright (c) 2025 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.impl.room.location
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare
fun RustLiveLocationShare.map(): LiveLocationShare {
return LiveLocationShare(
userId = UserId(userId),
lastGeoUri = lastLocation.location.geoUri,
lastTimestamp = lastLocation.ts.toLong(),
isLive = isLive,
)
}

View file

@ -0,0 +1,157 @@
/*
* 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.impl.room.threads
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.map
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
import org.matrix.rustcomponents.sdk.ThreadListUpdate
import uniffi.matrix_sdk_ui.ThreadListPaginationState
import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService
class RustThreadsListService(
private val inner: InnerThreadListService,
private val roomCoroutineScope: CoroutineScope,
private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(),
) : ThreadsListService {
private var itemSubscriptionJob: Job? = null
private val items = MutableStateFlow<List<ThreadListItem>>(emptyList())
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
if (itemSubscriptionJob?.isActive != true) {
itemSubscriptionJob = doSubscribeToItemUpdates()
}
return items
}
private fun doSubscribeToItemUpdates(): Job {
val updatesFlow = mxCallbackFlow {
inner.subscribeToItemsUpdates(object : ThreadListEntriesListener {
override fun onUpdate(diff: List<ThreadListUpdate>) {
trySend(diff)
}
})
}
return updatesFlow
.onStart { items.value = inner.items().map { it.map(contentMapper) } }
.onEach { diff ->
val updated = items.value.toMutableList()
updated.apply(diff, contentMapper)
items.value = updated
}
.launchIn(roomCoroutineScope)
}
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
return mxCallbackFlow {
inner.subscribeToPaginationStateUpdates(object : ThreadListPaginationStateListener {
override fun onUpdate(state: ThreadListPaginationState) {
trySend(state.map())
}
}).also {
// Send the initial state
trySend(inner.paginationState().map())
}
}
}
override suspend fun paginate(): Result<Unit> = runCatchingExceptions {
inner.paginate()
}
override suspend fun reset(): Result<Unit> = runCatchingExceptions {
inner.reset()
}
override fun destroy() {
itemSubscriptionJob?.cancel()
inner.destroy()
}
}
private fun MutableList<ThreadListItem>.apply(
diff: List<ThreadListUpdate>,
contentMapper: TimelineEventContentMapper
) {
for (diffItem in diff) {
when (diffItem) {
is ThreadListUpdate.Append -> {
val newItems = diffItem.values.map { it.map(contentMapper) }
addAll(newItems)
}
ThreadListUpdate.Clear -> clear()
is ThreadListUpdate.Insert -> {
add(diffItem.index.toInt(), diffItem.value.map(contentMapper))
}
ThreadListUpdate.PopBack -> {
removeAt(lastIndex)
}
ThreadListUpdate.PopFront -> {
removeAt(0)
}
is ThreadListUpdate.PushBack -> {
add(diffItem.value.map(contentMapper))
}
is ThreadListUpdate.PushFront -> {
add(0, diffItem.value.map(contentMapper))
}
is ThreadListUpdate.Remove -> {
removeAt(diffItem.index.toInt())
}
is ThreadListUpdate.Reset -> {
clear()
addAll(diffItem.values.map { it.map(contentMapper) })
}
is ThreadListUpdate.Set -> {
set(diffItem.index.toInt(), diffItem.value.map(contentMapper))
}
is ThreadListUpdate.Truncate -> {
subList(diffItem.length.toInt(), size).clear()
}
}
}
}
fun org.matrix.rustcomponents.sdk.ThreadListItem.map(contentMapper: TimelineEventContentMapper): ThreadListItem = ThreadListItem(
rootEvent = rootEvent.map(contentMapper),
latestEvent = latestEvent?.map(contentMapper),
numberOfReplies = numReplies.toLong(),
)
fun org.matrix.rustcomponents.sdk.ThreadListItemEvent.map(contentMapper: TimelineEventContentMapper): ThreadListItemEvent = ThreadListItemEvent(
eventId = EventId(eventId),
senderId = UserId(sender),
isOwn = isOwn,
senderProfile = senderProfile.map(),
content = content?.let(contentMapper::map),
timestamp = timestamp.toLong(),
)
fun ThreadListPaginationState.map(): ThreadListPaginationStatus = when (this) {
is ThreadListPaginationState.Idle -> ThreadListPaginationStatus.Idle(hasMoreToLoad = !endReached)
ThreadListPaginationState.Loading -> ThreadListPaginationStatus.Loading
}

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException
@ -129,6 +130,8 @@ class RustTimeline(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode is Timeline.Mode.FocusedOnEvent)
)
private val loggerTag = "Timeline($mode)"
init {
when (mode) {
is Timeline.Mode.Live, is Timeline.Mode.FocusedOnEvent -> coroutineScope.fetchMembers()
@ -176,10 +179,11 @@ class RustTimeline(
}
private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) {
when (direction) {
val result = when (direction) {
Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.getAndUpdate(update)
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update)
}
Timber.tag(loggerTag).d("updatePaginationStatus $direction: $result")
}
// Use NonCancellable to avoid breaking the timeline when the coroutine is cancelled.
@ -194,12 +198,13 @@ class RustTimeline(
}
}.onFailure { error ->
if (error is TimelineException.CannotPaginate) {
Timber.d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
Timber.tag(loggerTag).d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
} else {
updatePaginationStatus(direction) { it.copy(isPaginating = false) }
Timber.e(error, "Error paginating $direction on room ${joinedRoom.roomId}")
Timber.tag(loggerTag).e(error, "Error paginating $direction on room ${joinedRoom.roomId}")
}
}.onSuccess { hasReachedEnd ->
Timber.tag(loggerTag).d("Finished paginating $direction on room ${joinedRoom.roomId}, hasReachedEnd: $hasReachedEnd")
updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) }
}
}
@ -263,7 +268,7 @@ class RustTimeline(
try {
inner.fetchMembers()
} catch (exception: Exception) {
Timber.e(exception, "Error fetching members for room ${joinedRoom.roomId}")
Timber.tag(loggerTag).e(exception, "Error fetching members for room ${joinedRoom.roomId}")
}
}
@ -271,8 +276,16 @@ class RustTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType,
asPlainText: Boolean,
): Result<Unit> = withContext(dispatcher) {
MessageEventContent.from(body, htmlBody, intentionalMentions).use { content ->
MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions,
msgType = msgType,
asPlainText = asPlainText,
).use { content ->
runCatchingExceptions<Unit> {
inner.send(content)
}
@ -354,9 +367,15 @@ class RustTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions {
val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
val msg = MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions,
msgType = msgType,
)
inner.sendReply(
msg = msg,
eventId = repliedToEventId.value,
@ -372,7 +391,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending image ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending image ${file.path.hash()}")
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
inner.sendImage(
params = UploadParameters(
@ -398,7 +417,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending video ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending video ${file.path.hash()}")
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
inner.sendVideo(
params = UploadParameters(
@ -423,7 +442,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending audio ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending audio ${file.path.hash()}")
return sendAttachment(listOf(file)) {
inner.sendAudio(
params = UploadParameters(
@ -447,7 +466,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending file ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending file ${file.path.hash()}")
return sendAttachment(listOf(file)) {
inner.sendFile(
params = UploadParameters(
@ -477,7 +496,7 @@ class RustTimeline(
runCatchingExceptions {
roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds)
}.onFailure {
Timber.e(it)
Timber.tag(loggerTag).e(it)
}
}

View file

@ -232,7 +232,6 @@ private fun RustOtherState.map(): OtherState {
RustOtherState.PolicyRuleRoom -> OtherState.PolicyRuleRoom
RustOtherState.PolicyRuleServer -> OtherState.PolicyRuleServer
RustOtherState.PolicyRuleUser -> OtherState.PolicyRuleUser
RustOtherState.RoomAliases -> OtherState.RoomAliases
is RustOtherState.RoomAvatar -> OtherState.RoomAvatar(url)
RustOtherState.RoomCanonicalAlias -> OtherState.RoomCanonicalAlias
RustOtherState.RoomCreate -> OtherState.RoomCreate

View file

@ -9,20 +9,54 @@
package io.element.android.libraries.matrix.impl.util
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.impl.room.map
import org.matrix.rustcomponents.sdk.MessageContent
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.TextMessageContent
import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import org.matrix.rustcomponents.sdk.messageEventContentFromHtmlAsEmote
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdownAsEmote
/**
* Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
*/
object MessageEventContent {
fun from(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): RoomMessageEventContentWithoutRelation {
return if (htmlBody != null) {
messageEventContentFromHtml(body, htmlBody)
} else {
messageEventContentFromMarkdown(body)
}.withMentions(intentionalMentions.map())
fun from(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
asPlainText: Boolean = false,
): RoomMessageEventContentWithoutRelation {
return when {
asPlainText -> contentWithoutRelationFromMessage(
MessageContent(
msgType = MessageType.Text(
TextMessageContent(
body = body,
formatted = null,
)
),
body = body,
isEdited = false,
mentions = null,
)
)
htmlBody != null -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
messageEventContentFromHtmlAsEmote(body, htmlBody)
} else {
messageEventContentFromHtml(body, htmlBody)
}
else -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
messageEventContentFromMarkdownAsEmote(body)
} else {
messageEventContentFromMarkdown(body)
}
}
.withMentions(intentionalMentions.map())
}
}

View file

@ -0,0 +1,64 @@
/*
* 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.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverCapabilities
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RustHomeserverCapabilitiesProviderTest {
@Test
fun `refresh calls client refresh`() = runTest {
val refreshLambda = lambdaRecorder<Unit> {}
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda),
)
assertThat(provider.refresh().isSuccess).isTrue()
refreshLambda.assertions().isCalledOnce()
}
@Test
fun `refresh fails when client refresh does`() = runTest {
val refreshLambda = lambdaRecorder<Unit> { throw IllegalStateException("Failed to refresh capabilities") }
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda),
)
assertThat(provider.refresh().isFailure).isTrue()
refreshLambda.assertions().isCalledOnce()
}
@Test
fun `canChangeDisplayName returns expected value`() = runTest {
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { true }),
)
assertThat(provider.canChangeDisplayName().getOrNull()).isTrue()
}
@Test
fun `canChangeAvatarUrl returns expected value`() = runTest {
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(canChangeAvatar = { true }),
)
assertThat(provider.canChangeAvatarUrl().getOrNull()).isTrue()
}
@Test
fun `canChangeDisplayName returns failure when client throws`() = runTest {
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { throw IllegalStateException("Failed to get display name capability") }),
)
assert(provider.canChangeDisplayName().isFailure)
}
private fun createCapabilitiesProvider(
capabilities: FakeFfiHomeserverCapabilities = FakeFfiHomeserverCapabilities(),
) = RustHomeserverCapabilitiesProvider(capabilities)
}

View file

@ -14,10 +14,9 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import org.matrix.rustcomponents.sdk.EventOrTransactionId
import org.matrix.rustcomponents.sdk.EventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo
import org.matrix.rustcomponents.sdk.LazyTimelineItemProvider
import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.Receipt
import org.matrix.rustcomponents.sdk.ShieldState
import org.matrix.rustcomponents.sdk.TimelineItemContent
import uniffi.matrix_sdk_ui.EventItemOrigin
@ -26,37 +25,35 @@ internal fun aRustEventTimelineItem(
eventOrTransactionId: EventOrTransactionId = EventOrTransactionId.EventId(AN_EVENT_ID.value),
sender: String = A_USER_ID.value,
senderProfile: ProfileDetails = ProfileDetails.Unavailable,
forwarder: String? = null,
forwarderProfile: ProfileDetails? = null,
isOwn: Boolean = true,
isEditable: Boolean = true,
content: TimelineItemContent = aRustTimelineItemContentMsgLike(),
eventTypeRaw: String? = null,
timestamp: ULong = 0uL,
debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(),
localSendState: EventSendState? = null,
localCreatedAt: ULong? = null,
readReceipts: Map<String, Receipt> = emptyMap(),
origin: EventItemOrigin? = EventItemOrigin.SYNC,
canBeRepliedTo: Boolean = true,
shieldsState: ShieldState = ShieldState.None,
localCreatedAt: ULong? = null,
forwarder: String? = null,
forwarderProfile: ProfileDetails? = null,
lazyProvider: LazyTimelineItemProvider = FakeFfiLazyTimelineItemProvider(),
) = EventTimelineItem(
isRemote = isRemote,
eventOrTransactionId = eventOrTransactionId,
sender = sender,
senderProfile = senderProfile,
timestamp = timestamp,
isOwn = isOwn,
isEditable = isEditable,
canBeRepliedTo = canBeRepliedTo,
content = content,
localSendState = localSendState,
readReceipts = readReceipts,
origin = origin,
localCreatedAt = localCreatedAt,
lazyProvider = FakeFfiLazyTimelineItemProvider(
debugInfo = debugInfo,
shieldsState = shieldsState,
),
forwarder = forwarder,
forwarderProfile = forwarderProfile,
isOwn = isOwn,
isEditable = isEditable,
content = content,
eventTypeRaw = eventTypeRaw,
timestamp = timestamp,
localSendState = localSendState,
localCreatedAt = localCreatedAt,
readReceipts = readReceipts,
origin = origin,
canBeRepliedTo = canBeRepliedTo,
lazyProvider = lazyProvider,
)

View file

@ -17,6 +17,7 @@ import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.CreateRoomParameters
import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.NoHandle
@ -50,6 +51,7 @@ class FakeFfiClient(
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
private val closeResult: () -> Unit = {},
) : Client(NoHandle) {
override fun userId(): String = userId
@ -103,5 +105,9 @@ class FakeFfiClient(
return createRoomResult(request)
}
override fun homeserverCapabilities(): HomeserverCapabilities {
return homeserverCapabilities
}
override fun close() = closeResult()
}

View file

@ -0,0 +1,33 @@
/*
* 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.impl.fixtures.fakes
import io.element.android.tests.testutils.lambda.lambdaError
import org.matrix.rustcomponents.sdk.ExtendedProfileFields
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.NoHandle
class FakeFfiHomeserverCapabilities(
private val refresh: () -> Unit = { lambdaError() },
private val canChangeDisplayName: () -> Boolean = { lambdaError() },
private val canChangeAvatar: () -> Boolean = { lambdaError() },
private val canChangePassword: () -> Boolean = { lambdaError() },
private val canChangeThirdPartyIds: () -> Boolean = { lambdaError() },
private val canGetLoginToken: () -> Boolean = { lambdaError() },
private val forgetsRoomWhenLeaving: () -> Boolean = { lambdaError() },
private val extendedProfileFields: () -> ExtendedProfileFields = { lambdaError() },
) : HomeserverCapabilities(NoHandle) {
override suspend fun refresh() = refresh.invoke()
override suspend fun canChangeDisplayname(): Boolean = canChangeDisplayName.invoke()
override suspend fun canChangeAvatar(): Boolean = canChangeAvatar.invoke()
override suspend fun canChangePassword(): Boolean = canChangePassword.invoke()
override suspend fun canChangeThirdpartyIds(): Boolean = canChangeThirdPartyIds.invoke()
override suspend fun canGetLoginToken(): Boolean = canGetLoginToken.invoke()
override suspend fun forgetsRoomWhenLeaving(): Boolean = forgetsRoomWhenLeaving.invoke()
override suspend fun extendedProfileFields(): ExtendedProfileFields = extendedProfileFields.invoke()
}

View file

@ -0,0 +1,58 @@
/*
* 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.impl.fixtures.fakes
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
import org.matrix.rustcomponents.sdk.ThreadListItem
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
import org.matrix.rustcomponents.sdk.ThreadListService
import org.matrix.rustcomponents.sdk.ThreadListUpdate
import uniffi.matrix_sdk_ui.ThreadListPaginationState
class FakeFfiThreadListService(
private val subscribeToItemsUpdates: (ThreadListEntriesListener) -> TaskHandle = { FakeFfiTaskHandle() },
private val subscribeToPaginationStateUpdates: (ThreadListPaginationStateListener) -> TaskHandle = { FakeFfiTaskHandle() },
private val items: () -> List<ThreadListItem> = { emptyList() },
private val paginationState: () -> ThreadListPaginationState = { ThreadListPaginationState.Idle(endReached = false) },
private val paginate: suspend () -> Unit = {},
private val reset: suspend () -> Unit = {},
private val destroy: () -> Unit = {},
) : ThreadListService(NoHandle) {
private var itemsListener: ThreadListEntriesListener? = null
private var paginationStateListener: ThreadListPaginationStateListener? = null
override fun subscribeToItemsUpdates(listener: ThreadListEntriesListener): TaskHandle {
itemsListener = listener
return subscribeToItemsUpdates.invoke(listener)
}
override fun subscribeToPaginationStateUpdates(listener: ThreadListPaginationStateListener): TaskHandle {
paginationStateListener = listener
return subscribeToPaginationStateUpdates.invoke(listener)
}
override fun items(): List<ThreadListItem> = items.invoke()
override fun paginationState(): ThreadListPaginationState = paginationState.invoke()
override suspend fun paginate() = paginate.invoke()
override suspend fun reset() = reset.invoke()
override fun destroy() = destroy.invoke()
fun emitUpdates(updates: List<ThreadListUpdate>) {
itemsListener?.onUpdate(updates)
}
fun emitPaginationState(state: ThreadListPaginationState) {
paginationStateListener?.onUpdate(state)
}
}

View file

@ -20,7 +20,6 @@ class StateEventTypeTest {
assertThat(RustStateEventType.PolicyRuleRoom.map()).isEqualTo(StateEventType.PolicyRuleRoom)
assertThat(RustStateEventType.PolicyRuleServer.map()).isEqualTo(StateEventType.PolicyRuleServer)
assertThat(RustStateEventType.PolicyRuleUser.map()).isEqualTo(StateEventType.PolicyRuleUser)
assertThat(RustStateEventType.RoomAliases.map()).isEqualTo(StateEventType.RoomAliases)
assertThat(RustStateEventType.RoomAvatar.map()).isEqualTo(StateEventType.RoomAvatar)
assertThat(RustStateEventType.RoomCanonicalAlias.map()).isEqualTo(StateEventType.RoomCanonicalAlias)
assertThat(RustStateEventType.RoomCreate.map()).isEqualTo(StateEventType.RoomCreate)
@ -47,7 +46,6 @@ class StateEventTypeTest {
assertThat(StateEventType.PolicyRuleRoom.map()).isEqualTo(RustStateEventType.PolicyRuleRoom)
assertThat(StateEventType.PolicyRuleServer.map()).isEqualTo(RustStateEventType.PolicyRuleServer)
assertThat(StateEventType.PolicyRuleUser.map()).isEqualTo(RustStateEventType.PolicyRuleUser)
assertThat(StateEventType.RoomAliases.map()).isEqualTo(RustStateEventType.RoomAliases)
assertThat(StateEventType.RoomAvatar.map()).isEqualTo(RustStateEventType.RoomAvatar)
assertThat(StateEventType.RoomCanonicalAlias.map()).isEqualTo(RustStateEventType.RoomCanonicalAlias)
assertThat(StateEventType.RoomCreate.map()).isEqualTo(RustStateEventType.RoomCreate)

View file

@ -0,0 +1,143 @@
/*
* 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.impl.room.threads
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustTimelineItemContentMsgLike
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTaskHandle
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiThreadListService
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
import org.matrix.rustcomponents.sdk.ThreadListItem
import org.matrix.rustcomponents.sdk.ThreadListItemEvent
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
import org.matrix.rustcomponents.sdk.ThreadListUpdate
import uniffi.matrix_sdk_ui.ThreadListPaginationState
@OptIn(ExperimentalCoroutinesApi::class)
class RustThreadsListServiceTest {
@Test
fun `subscribing to item updates calls the FFI method and allows retrieving new items`() = runTest {
val subscribeToItemsUpdatesRecorder = lambdaRecorder<ThreadListEntriesListener, TaskHandle> { FakeFfiTaskHandle() }
val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder)
val service = createThreadsListService(inner = inner)
service.subscribeToItemUpdates().test {
assertThat(awaitItem()).isEmpty()
runCurrent()
subscribeToItemsUpdatesRecorder.assertions().isCalledOnce()
inner.emitUpdates(listOf(aRustThreadListUpdate()))
assertThat(awaitItem()).isNotEmpty()
}
}
@Suppress("UnusedFlow")
@Test
fun `subscribing to item updates twice only calls the FFI method once`() = runTest {
val subscribeToItemsUpdatesRecorder = lambdaRecorder<ThreadListEntriesListener, TaskHandle> { FakeFfiTaskHandle() }
val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder)
val service = createThreadsListService(inner = inner)
service.subscribeToItemUpdates()
service.subscribeToItemUpdates()
runCurrent()
subscribeToItemsUpdatesRecorder.assertions().isCalledOnce()
}
@Test
fun `subscribing to pagination updates calls the FFI method and allows retrieving new items`() = runTest {
val subscribeToPaginationUpdatesRecorder = lambdaRecorder<ThreadListPaginationStateListener, TaskHandle> { FakeFfiTaskHandle() }
val inner = FakeFfiThreadListService(subscribeToPaginationStateUpdates = subscribeToPaginationUpdatesRecorder)
val service = createThreadsListService(inner = inner)
service.subscribeToPaginationUpdates().test {
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Idle(hasMoreToLoad = true))
runCurrent()
subscribeToPaginationUpdatesRecorder.assertions().isCalledOnce()
inner.emitPaginationState(ThreadListPaginationState.Loading)
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Loading)
}
}
@Test
fun `paginate calls the FFI method`() = runTest {
val paginateRecorder = lambdaRecorder<Unit> {}
val inner = FakeFfiThreadListService(paginate = paginateRecorder)
val service = createThreadsListService(inner = inner)
service.paginate()
paginateRecorder.assertions().isCalledOnce()
}
@Test
fun `reset calls the FFI method`() = runTest {
val resetRecorder = lambdaRecorder<Unit> {}
val inner = FakeFfiThreadListService(reset = resetRecorder)
val service = createThreadsListService(inner = inner)
service.reset()
resetRecorder.assertions().isCalledOnce()
}
@Test
fun `destroy calls the FFI method`() = runTest {
val destroyRecorder = lambdaRecorder<Unit> {}
val inner = FakeFfiThreadListService(destroy = destroyRecorder)
val service = createThreadsListService(inner = inner)
service.destroy()
destroyRecorder.assertions().isCalledOnce()
}
private fun TestScope.createThreadsListService(
inner: FakeFfiThreadListService = FakeFfiThreadListService(),
) = RustThreadsListService(
inner = inner,
roomCoroutineScope = backgroundScope,
)
private fun aRustThreadListUpdate() = ThreadListUpdate.Append(
values = listOf(
ThreadListItem(
rootEvent = ThreadListItemEvent(
eventId = AN_EVENT_ID.value,
timestamp = A_TIMESTAMP.toULong(),
sender = A_USER_ID.value,
senderProfile = ProfileDetails.Pending,
isOwn = true,
content = aRustTimelineItemContentMsgLike(),
),
numReplies = 0u,
latestEvent = null,
)
),
)
}

View file

@ -19,7 +19,6 @@ dependencies {
api(projects.libraries.matrix.api)
api(libs.coroutines.core)
implementation(libs.coroutines.test)
implementation(projects.libraries.matrix.impl)
implementation(projects.services.analytics.api)
implementation(projects.tests.testutils)
implementation(libs.kotlinx.collections.immutable)

View file

@ -0,0 +1,20 @@
/*
* 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
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
class FakeHomeserverCapabilitiesProvider(
private val refresh: () -> Result<Unit> = { Result.success(Unit) },
private val canChangeDisplayName: () -> Result<Boolean> = { Result.success(true) },
private val canChangeAvatarUrl: () -> Result<Boolean> = { Result.success(true) },
) : HomeserverCapabilitiesProvider {
override suspend fun refresh(): Result<Unit> = refresh.invoke()
override suspend fun canChangeDisplayName(): Result<Boolean> = canChangeDisplayName.invoke()
override suspend fun canChangeAvatarUrl(): Result<Boolean> = canChangeAvatarUrl.invoke()
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.DeviceId
@ -84,6 +85,7 @@ class FakeMatrixClient(
override val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
Result.success(
@ -384,4 +386,8 @@ class FakeMatrixClient(
override suspend fun resetWellKnownConfig(): Result<Unit> {
return resetWellKnownConfigLambda()
}
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
return homeserverCapabilitiesProvider
}
}

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.test.auth
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ -17,6 +18,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -32,6 +34,8 @@ class FakeMatrixAuthenticationService(
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
private val importCreatedSessionLambda: (ExternalSession) -> Result<SessionId> = { lambdaError() },
private val setHomeserverResult: (String) -> Result<MatrixHomeServerDetails> = { lambdaError() },
private val setElementClassicSessionResult: (ElementClassicSession?) -> Unit = { lambdaError() },
private val doSecretsContainBackupKeyResult: (UserId, String, String) -> Boolean = { _, _, _ -> lambdaError() },
) : MatrixAuthenticationService {
private var oidcError: Throwable? = null
private var oidcCancelError: Throwable? = null
@ -108,4 +112,12 @@ class FakeMatrixAuthenticationService(
fun givenMatrixClient(matrixClient: MatrixClient) {
this.matrixClient = matrixClient
}
override fun setElementClassicSession(session: ElementClassicSession?) {
setElementClassicSessionResult(session)
}
override fun doSecretsContainBackupKey(userId: UserId, secrets: String, backupInfo: String): Boolean {
return doSecretsContainBackupKeyResult(userId, secrets, backupInfo)
}
}

View file

@ -8,8 +8,12 @@
package io.element.android.libraries.matrix.test.mxc
import io.element.android.libraries.matrix.api.mxc.MxcTools
import io.element.android.libraries.matrix.impl.mxc.DefaultMxcTools
import io.element.android.tests.testutils.lambda.lambdaError
class FakeMxcTools(
private val delegate: MxcTools = DefaultMxcTools()
) : MxcTools by delegate
private val mxcUri2FilePathResult: (String) -> String? = { lambdaError() }
) : MxcTools {
override fun mxcUri2FilePath(mxcUri: String): String? {
return mxcUri2FilePathResult(mxcUri)
}
}

View file

@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
@ -56,6 +57,7 @@ class FakeJoinedRoom(
override val roomNotificationSettingsStateFlow: StateFlow<RoomNotificationSettingsState> =
MutableStateFlow(RoomNotificationSettingsState.Unknown),
override val knockRequestsFlow: Flow<List<KnockRequest>> = MutableStateFlow(emptyList()),
override val threadsListService: FakeThreadsListService = FakeThreadsListService(),
private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
private var createTimelineResult: (CreateTimelineParams) -> Result<Timeline> = { lambdaError() },
private val editMessageLambda: (EventId, String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() },

View file

@ -0,0 +1,48 @@
/*
* 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.room.threads
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeThreadsListService(
private val items: MutableStateFlow<List<ThreadListItem>> = MutableStateFlow(emptyList()),
private val paginationStatus: MutableStateFlow<ThreadListPaginationStatus> = MutableStateFlow(ThreadListPaginationStatus.Idle(hasMoreToLoad = true)),
private val subscribeToItemUpdates: () -> Flow<List<ThreadListItem>> = { items },
private val subscribeToPaginationUpdates: () -> Flow<ThreadListPaginationStatus> = { paginationStatus },
private val paginate: suspend () -> Result<Unit> = { Result.success(Unit) },
private val reset: suspend () -> Result<Unit> = { Result.success(Unit) },
private val destroy: () -> Unit = {},
) : ThreadsListService {
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
return subscribeToItemUpdates.invoke()
}
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
return subscribeToPaginationUpdates.invoke()
}
override suspend fun paginate(): Result<Unit> {
return paginate.invoke()
}
override suspend fun reset(): Result<Unit> {
return reset.invoke()
}
override fun destroy() {
return destroy.invoke()
}
suspend fun emit(items: List<ThreadListItem>) {
this.items.emit(items)
}
}

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -78,7 +79,9 @@ class FakeTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
) -> Result<Unit> = { _, _, _ ->
msgType: MsgType,
asPlainText: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
lambdaError()
}
@ -90,8 +93,10 @@ class FakeTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType,
asPlainText: Boolean,
): Result<Unit> = simulateLongTask {
sendMessageLambda(body, htmlBody, intentionalMentions)
sendMessageLambda(body, htmlBody, intentionalMentions, msgType, asPlainText)
}
var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result<Unit> = { _, _ ->
@ -148,7 +153,8 @@ class FakeTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
msgType: MsgType,
) -> Result<Unit> = { _, _, _, _, _, _ ->
lambdaError()
}
@ -158,12 +164,14 @@ class FakeTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = replyMessageLambda(
repliedToEventId,
body,
htmlBody,
intentionalMentions,
fromNotification,
msgType,
)
var sendImageLambda: (

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -22,9 +23,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
@ -33,6 +38,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.R
@ -48,10 +54,23 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun CreateDmConfirmationBottomSheet(
matrixUser: MatrixUser,
enableKeyShareOnInvite: Boolean,
isUserIdentityUnknown: Boolean,
onSendInvite: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val titleContent = if (enableKeyShareOnInvite && isUserIdentityUnknown) {
stringResource(R.string.screen_bottom_sheet_create_dm_unknown_user_title)
} else {
stringResource(R.string.screen_bottom_sheet_create_dm_title)
}
val descriptionContent = if (enableKeyShareOnInvite && isUserIdentityUnknown) {
stringResource(R.string.screen_bottom_sheet_create_dm_unknown_user_content)
} else {
stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName())
}
ModalBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
@ -63,47 +82,95 @@ fun CreateDmConfirmationBottomSheet(
.padding(top = 24.dp, bottom = 16.dp, start = 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(
avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.screen_bottom_sheet_create_dm_title),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName()),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onSendInvite,
leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()),
text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title),
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = onDismiss,
text = stringResource(CommonStrings.action_cancel),
)
if (isUserIdentityUnknown) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(
bottom = 16.dp,
start = 16.dp,
end = 16.dp,
),
title = titleContent,
subTitle = descriptionContent,
iconStyle = BigIcon.Style.Default(CompoundIcons.UserAddSolid()),
)
MatrixUserRow(matrixUser)
Spacer(modifier = Modifier.height(32.dp))
ButtonRowMolecule(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedButton(
modifier = Modifier.weight(1f),
text = stringResource(CommonStrings.action_cancel),
onClick = onDismiss
)
Button(
modifier = Modifier.weight(1f),
text = stringResource(CommonStrings.action_continue),
onClick = onSendInvite
)
}
Spacer(modifier = Modifier.height(32.dp))
} else {
Avatar(
avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = titleContent,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = descriptionContent,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onSendInvite,
leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()),
text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title),
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = onDismiss,
text = stringResource(CommonStrings.action_cancel),
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(
CreateDmConfirmationBottomSheetStateProvider::class
) state: CreateDmConfirmationBottomSheetState) = ElementPreview {
CreateDmConfirmationBottomSheet(
matrixUser = matrixUser,
matrixUser = state.matrixUser,
enableKeyShareOnInvite = state.enableKeyShareOnInvite,
isUserIdentityUnknown = state.isUserIdentityUnknown,
onSendInvite = {},
onDismiss = {},
)
}
data class CreateDmConfirmationBottomSheetState(
val matrixUser: MatrixUser,
val enableKeyShareOnInvite: Boolean,
val isUserIdentityUnknown: Boolean,
)
class CreateDmConfirmationBottomSheetStateProvider : PreviewParameterProvider<CreateDmConfirmationBottomSheetState> {
override val values = sequenceOf(
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = false, isUserIdentityUnknown = false),
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = true, isUserIdentityUnknown = false),
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = true, isUserIdentityUnknown = true),
)
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"招待を送信"</string>
<string name="screen_bottom_sheet_create_dm_message">"%1$s とチャットを始めますか?"</string>
<string name="screen_bottom_sheet_create_dm_title">"招待を送信しますか?"</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_content">"この人物とのチャットがありません。続行する前に、まず招待してください。"</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_title">"この新しい連絡先と新規にチャットを開始しますか?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) があなたを招待しました"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s(%2$s ) đã mời bạn"</string>
</resources>

View file

@ -3,5 +3,7 @@
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Send invite"</string>
<string name="screen_bottom_sheet_create_dm_message">"Would you like to start a chat with %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Send invite?"</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_content">"You currently dont have any chats with this person. Confirm inviting them before continuing."</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_title">"Start a chat with this new contact?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
</resources>

View file

@ -195,18 +195,16 @@ class AndroidMediaPreProcessor(
file = file,
mimeType = mimeType,
)
val imageInfo = contentResolver.openInputStream(uri).use { input ->
val bitmap = BitmapFactory.decodeStream(input, null, null)!!
ImageInfo(
width = bitmap.width.toLong(),
height = bitmap.height.toLong(),
mimetype = mimeType,
size = file.length(),
thumbnailInfo = thumbnailResult?.info,
thumbnailSource = null,
blurhash = thumbnailResult?.blurhash,
)
}
val (width, height) = extractOrientedImageDimensions(file)
val imageInfo = ImageInfo(
width = width,
height = height,
mimetype = mimeType,
size = file.length(),
thumbnailInfo = thumbnailResult?.info,
thumbnailSource = null,
blurhash = thumbnailResult?.blurhash,
)
removeSensitiveImageMetadata(file)
return MediaUploadInfo.Image(
file = file,
@ -354,6 +352,23 @@ class AndroidMediaPreProcessor(
return contentResolver.openInputStream(uri)?.use { createTmpFileWithInput(it) }
?: error("Could not copy the contents of $uri to a temporary file")
}
private fun extractOrientedImageDimensions(file: File): Pair<Long, Long> {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, options)
val rawWidth = options.outWidth.toLong()
val rawHeight = options.outHeight.toLong()
val orientation = tryOrNull {
ExifInterface(file).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
} ?: ExifInterface.ORIENTATION_UNDEFINED
return orientedImageDimensions(
rawWidth = rawWidth,
rawHeight = rawHeight,
orientation = orientation,
)
}
}
private fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailResult: ThumbnailResult?) = ImageInfo(
@ -371,3 +386,18 @@ private fun MediaMetadataRetriever.extractDuration(): Duration {
val durationInMs = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
return durationInMs.milliseconds
}
internal fun orientedImageDimensions(rawWidth: Long, rawHeight: Long, orientation: Int): Pair<Long, Long> {
return if (orientation.rotatesRightAngle()) {
rawHeight to rawWidth
} else {
rawWidth to rawHeight
}
}
private fun Int.rotatesRightAngle(): Boolean {
return this == ExifInterface.ORIENTATION_ROTATE_90 ||
this == ExifInterface.ORIENTATION_ROTATE_270 ||
this == ExifInterface.ORIENTATION_TRANSPOSE ||
this == ExifInterface.ORIENTATION_TRANSVERSE
}

View file

@ -12,6 +12,7 @@ import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
@ -42,6 +43,30 @@ import kotlin.time.Duration
@RunWith(RobolectricTestRunner::class)
class AndroidMediaPreProcessorTest {
@Test
fun `orientedImageDimensions swaps width and height for 90 degree exif orientation`() {
val (width, height) = orientedImageDimensions(
rawWidth = 4032,
rawHeight = 2268,
orientation = ExifInterface.ORIENTATION_ROTATE_90,
)
assertThat(width).isEqualTo(2268)
assertThat(height).isEqualTo(4032)
}
@Test
fun `orientedImageDimensions keeps width and height for upright exif orientation`() {
val (width, height) = orientedImageDimensions(
rawWidth = 4032,
rawHeight = 2268,
orientation = ExifInterface.ORIENTATION_NORMAL,
)
assertThat(width).isEqualTo(4032)
assertThat(height).isEqualTo(2268)
}
private suspend fun TestScope.process(
asset: Asset,
mediaOptimizationConfig: MediaOptimizationConfig,

View file

@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -51,6 +52,7 @@ import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.audio.api.AudioFocus
@ -130,6 +132,8 @@ private fun ExoPlayerMediaAudioView(
mutableStateOf(null)
}
val isTargetVisible = LocalNodeTargetVisibility.current
val playableState: PlayableState.Playable by remember {
derivedStateOf {
PlayableState.Playable(
@ -196,13 +200,21 @@ private fun ExoPlayerMediaAudioView(
exoPlayer.pause()
}
}
LaunchedEffect(isTargetVisible) {
if (!isTargetVisible) {
exoPlayer.pause()
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
}
} else {
exoPlayer.setMediaItems(emptyList())
LaunchedEffect(Unit) {
exoPlayer.setMediaItems(emptyList())
}
}
val context = LocalContext.current
val waveform = info?.waveform
@ -247,7 +259,7 @@ private fun ExoPlayerMediaAudioView(
}
},
update = { playerView ->
playerView.isVisible = metadata.hasArtwork()
playerView.isVisible = metadata.hasArtwork() && isTargetVisible
},
onRelease = { playerView ->
playerView.player = null
@ -317,16 +329,19 @@ private fun ExoPlayerMediaAudioView(
)
}
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> exoPlayer.addListener(playerListener)
Lifecycle.Event.ON_RESUME -> exoPlayer.prepare()
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
DisposableEffect(exoPlayer) {
exoPlayer.addListener(playerListener)
onDispose {
if (!exoPlayer.isReleased) {
exoPlayer.removeListener(playerListener)
exoPlayer.release()
}
else -> Unit
}
}
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_PAUSE) {
exoPlayer.pause()
}
}
}

View file

@ -23,7 +23,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import io.element.android.libraries.ui.strings.CommonStrings
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.coil3.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
@Composable

View file

@ -29,6 +29,14 @@ import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirm
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import kotlinx.collections.immutable.toImmutableList
private const val LONG_CAPTION = "This is a very long caption that should be scrollable in the media viewer. " +
"It contains multiple lines of text to demonstrate the scrolling behavior. " +
"Line 1: Lorem ipsum dolor sit amet, consectetur adipiscing elit. " +
"Line 2: Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " +
"Line 3: Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. " +
"Line 4: Duis aute irure dolor in reprehenderit in voluptate velit esse cillum. " +
"Line 5: Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia."
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
override val values: Sequence<MediaViewerState>
get() = sequenceOf(
@ -170,6 +178,22 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
)
)
),
anImageMediaInfo(
senderName = "Alice",
dateSent = "21 NOV, 2024",
caption = LONG_CAPTION,
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
)
}

View file

@ -17,18 +17,24 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -39,14 +45,17 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -69,6 +78,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.hasCompactHeightWindowSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.media.MediaSource
@ -102,8 +112,9 @@ fun MediaViewerView(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
var showOverlay by remember { mutableStateOf(true) }
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0
val currentData = state.listData.getOrNull(state.currentIndex)
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current && !hasCompactHeightWindowSize()) 303 else 0
BackHandler { onBackClick() }
Scaffold(
modifier,
@ -153,10 +164,11 @@ fun MediaViewerView(
// So we need to update this value only when the `settledPage` value changes. It seems like a bug that needs to be fixed in Compose.
page == pagerState.settledPage
}
val navigationBarPadding = WindowInsets.navigationBars.getBottom(LocalDensity.current)
MediaViewerPage(
isDisplayed = isDisplayed,
showOverlay = showOverlay,
bottomPaddingInPixels = bottomPaddingInPixels,
bottomPaddingInPixels = (bottomPaddingInPixels - navigationBarPadding).coerceAtLeast(0),
data = dataForPage,
textFileViewer = textFileViewer,
onDismiss = onBackClick,
@ -175,9 +187,7 @@ fun MediaViewerView(
// Bottom bar
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
modifier = Modifier.fillMaxSize()
) {
MediaViewerBottomBar(
modifier = Modifier.align(Alignment.BottomCenter),
@ -538,19 +548,46 @@ private fun MediaViewerBottomBar(
if (showDivider) {
HorizontalDivider()
}
Text(
val scrollState = rememberScrollState()
val showBottomShadow by remember { derivedStateOf { scrollState.value < scrollState.maxValue } }
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
text = caption,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
)
.heightIn(max = if (hasCompactHeightWindowSize()) maxCaptionHeightLandscape else maxCaptionHeightPortrait),
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(scrollState)
.navigationBarsPadding(),
text = caption,
style = ElementTheme.typography.fontBodyLgRegular,
)
if (showBottomShadow) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.align(Alignment.BottomCenter)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
bgCanvasWithTransparency,
),
),
),
)
}
}
}
}
}
private val maxCaptionHeightPortrait = 200.dp
private val maxCaptionHeightLandscape = 128.dp
@Composable
private fun ThumbnailView(
thumbnailSource: MediaSource?,
@ -604,3 +641,14 @@ internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::
onBackClick = {},
)
}
@Preview(device = "${Devices.PHONE}, orientation=landscape")
@Composable
internal fun MediaViewerViewLandscapePreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark {
MediaViewerView(
state = state,
audioFocus = null,
textFileViewer = { _, _ -> },
onBackClick = {},
)
}

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"このファイルはルームから削除され、他のユーザーは確認することができなくなります。"</string>
<string name="screen_media_browser_delete_confirmation_title">"ファイルを削除しますか?"</string>
<string name="screen_media_browser_download_error_message">"インターネット接続を確認した上、再度お試しください。"</string>
<string name="screen_media_browser_files_empty_state_subtitle">"このルームに投稿された文書ファイルや音声ファイル・メッセージはここに表示されます。"</string>
<string name="screen_media_browser_files_empty_state_title">"アップロードされたファイルはありません"</string>
<string name="screen_media_browser_list_loading_files">"ファイルを読み込み中…"</string>
<string name="screen_media_browser_list_loading_media">"メディアを読み込み中…"</string>
<string name="screen_media_browser_list_mode_files">"ファイル"</string>
<string name="screen_media_browser_list_mode_media">"メディア"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"このルームに投稿された画像と動画はここに表示されます。"</string>
<string name="screen_media_browser_media_empty_state_title">"アップロードされたメディアはありません"</string>
<string name="screen_media_browser_title">"ファイルとメディア"</string>
<string name="screen_media_details_file_format">"ファイル形式"</string>
<string name="screen_media_details_filename">"ファイル名"</string>
<string name="screen_media_details_no_more_files_to_show">"これ以上ファイルはありません"</string>
<string name="screen_media_details_no_more_media_to_show">"これ以上メディアはありません"</string>
<string name="screen_media_details_uploaded_by">"アップロード元"</string>
<string name="screen_media_details_uploaded_on">"アップロード先"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="dialog_permission_camera">"カメラを使用するには、本体の設定から権限を付与する必要があります。"</string>
<string name="dialog_permission_generic">"本体の設定から権限を付与してください。"</string>
<string name="dialog_permission_microphone">"マイクを使用するには、本体の設定から権限を付与してください。"</string>
<string name="dialog_permission_notification">"通知を表示するには、本体の設定から権限を付与してください。"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="dialog_permission_camera">"Để ứng dụng sử dụng camera, vui lòng cấp quyền trong cài đặt hệ thống."</string>
<string name="dialog_permission_generic">"Vui lòng cấp quyền trong cài đặt hệ thống."</string>
<string name="dialog_permission_microphone">"Để ứng dụng có thể sử dụng micro, vui lòng cấp quyền trong cài đặt hệ thống."</string>
<string name="dialog_permission_notification">"Để ứng dụng hiển thị thông báo, vui lòng cấp quyền trong cài đặt hệ thống."</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="troubleshoot_notifications_test_check_permission_description">"アプリケーションが通知を表示できることを確認してください。"</string>
<string name="troubleshoot_notifications_test_check_permission_title">"権限の確認"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="troubleshoot_notifications_test_check_permission_description">"Kiểm tra xem ứng dụng có hiển thị thông báo hay không."</string>
<string name="troubleshoot_notifications_test_check_permission_title">"Kiểm tra quyền truy cập"</string>
</resources>

View file

@ -302,7 +302,6 @@ class DefaultNotifiableEventResolver(
NotificationContent.StateEvent.PolicyRuleRoom,
NotificationContent.StateEvent.PolicyRuleServer,
NotificationContent.StateEvent.PolicyRuleUser,
NotificationContent.StateEvent.RoomAliases,
NotificationContent.StateEvent.RoomAvatar,
NotificationContent.StateEvent.RoomCanonicalAlias,
NotificationContent.StateEvent.RoomCreate,

View file

@ -14,7 +14,6 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
import timber.log.Timber
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration
@ContributesBinding(AppScope::class)
@ -22,24 +21,13 @@ import kotlin.time.Duration
class DefaultPushHandlingWakeLock(
@ApplicationContext private val context: Context,
) : PushHandlingWakeLock {
private val count = AtomicInteger(0)
override fun lock(time: Duration) {
Timber.d("Acquiring wakelock for push handling, starting service.")
FetchPushForegroundService.startIfNeeded(context)
count.incrementAndGet()
}
override suspend fun unlock() {
Timber.d("Releasing wakelock used for push handling.")
FetchPushForegroundService.stop(context)
if (count.decrementAndGet() <= 0) {
Timber.d("No more wakelock needed for push handling, stopping service.")
count.set(0)
} else {
Timber.d("Wakelock still needed for push handling, restarting service | count: ${count.get()}.")
FetchPushForegroundService.startIfNeeded(context)
}
}
}

View file

@ -17,6 +17,7 @@ import android.os.PowerManager
import androidx.core.app.NotificationCompat
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
@ -57,6 +58,8 @@ class FetchPushForegroundService : Service() {
}
}
private var isOnForeground = false
override fun onCreate() {
Timber.d("Creating FetchPushForegroundService")
@ -71,19 +74,39 @@ class FetchPushForegroundService : Service() {
.setVibrate(longArrayOf(0))
.setSound(null)
.build()
startForeground(NOTIFICATION_ID, notificationCompat)
// Try to start the service in foreground. This can fail, even in cases where it's supposed to work according to the docs.
// In those cases we catch the exception and handle the failure so we don't try to start the wakelock or stop the service
// from running in foreground later.
runCatchingExceptions {
startForeground(NOTIFICATION_ID, notificationCompat)
}
.onSuccess {
isOnForeground = true
Timber.d("FetchPushForegroundService started in foreground successfully")
}
.onFailure {
isOnForeground = false
Timber.e(it, "Failed to start FetchPushForegroundService in foreground")
}
super.onCreate()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!isOnForeground) {
Timber.w("FetchPushForegroundService is not running in foreground, stopping it to avoid crash")
stopSelf()
return START_NOT_STICKY
}
wakelock.acquire(wakelockTimeout)
// The timeout is not automatic before Android 15, so we need to schedule it ourselves
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
coroutineScope.launch {
delay(wakelockTimeout)
onTimeout(startId)
onTimeoutAction(calledByTheSystem = false)
}
}
@ -91,18 +114,25 @@ class FetchPushForegroundService : Service() {
}
override fun stopService(intent: Intent?): Boolean {
wakelock.release()
if (isOnForeground) {
wakelock.release()
stopForeground(STOP_FOREGROUND_REMOVE)
}
stopForeground(STOP_FOREGROUND_REMOVE)
return super.stopService(intent)
}
override fun onTimeout(startId: Int) {
super.onTimeout(startId)
onTimeoutAction(calledByTheSystem = true)
}
Timber.d("Wakelock timeout reached, stopping FetchPushForegroundService")
coroutineScope.launch { pushHandlingWakeLock.unlock() }
private fun onTimeoutAction(calledByTheSystem: Boolean) {
Timber.d("onTimeoutAction, calledByTheSystem: $calledByTheSystem, isOnForeground: $isOnForeground")
if (isOnForeground) {
Timber.d("Wakelock timeout reached, stopping FetchPushForegroundService")
coroutineScope.launch { pushHandlingWakeLock.unlock() }
}
}
companion object {
@ -119,7 +149,13 @@ class FetchPushForegroundService : Service() {
fun start(context: Context) {
val intent = Intent(context, FetchPushForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
runCatchingExceptions { context.startForegroundService(intent) }
.onFailure { throwable ->
Timber.e(
throwable,
"Failed to start FetchPushForegroundService, notifications may take longer than usual to sync"
)
}
} else {
context.startService(intent)
}

View file

@ -76,7 +76,6 @@ class DefaultSyncPendingNotificationsRequestBuilder(
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
// If we're in an air-gapped environment, we shouldn't validate internet connectivity, as the checker will fail and the worker won't run at all.
// Note this will always be false for FOSS, since the feature is only enabled in Element Pro.
if (networkMonitor.isInAirGappedEnvironment.first()) {
Timber.d("In an air-gapped environment, not adding NET_CAPABILITY_VALIDATED to the network request")
networkRequestBuilder.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)

View file

@ -15,6 +15,11 @@
</plurals>
<string name="notification_error_unified_push_unregistered_android">"Non è stato possibile registrare il distributore di notifiche UnifiedPush, quindi non riceverai più notifiche. Controlla le impostazioni delle notifiche dell\'app e lo stato del distributore push."</string>
<string name="notification_fallback_content">"Hai nuovi messaggi."</string>
<plurals name="notification_fallback_n_content">
<item quantity="one">"Hai %d nuovo messaggio."</item>
<item quantity="other">"Hai %d nuovi messaggi."</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 Chiamata in arrivo"</string>
<string name="notification_incoming_call">"📹 Chiamata in arrivo"</string>
<string name="notification_inline_reply_failed">"** Invio fallito - si prega di aprire la stanza"</string>
<string name="notification_invitation_action_join">"Entra"</string>
@ -38,6 +43,8 @@
<string name="notification_room_invite_body_with_sender">"%1$s ti ha invitato a unirti alla stanza"</string>
<string name="notification_sender_me">"Io"</string>
<string name="notification_sender_mention_reply">"%1$s ti ha menzionato o risposto"</string>
<string name="notification_space_invite_body">"Ti abbiamo invitato a unirti allo spazio"</string>
<string name="notification_space_invite_body_with_sender">"%1$s ti ha invitato a unirti allo spazio"</string>
<string name="notification_test_push_notification_content">"Stai visualizzando la notifica! Cliccami!"</string>
<string name="notification_thread_in_room">"Discussione in %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>

View file

@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"通話"</string>
<string name="notification_channel_listening_for_events">"イベントを監視中"</string>
<string name="notification_channel_noisy">"通常の通知"</string>
<string name="notification_channel_ringing_calls">"着信"</string>
<string name="notification_channel_silent">"サイレント通知"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="other">"%1$s: %2$d件のメッセージ"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="other">"%d 件の通知"</item>
</plurals>
<string name="notification_error_unified_push_unregistered_android">"Unified Push の通知配信サービス (notification distributor) を登録できないため、通知を受け取ることができません。通知の設定と通知ディストリビューター (push distributor) の状況を確認してください。"</string>
<string name="notification_fallback_content">"新着メッセージがあります。"</string>
<plurals name="notification_fallback_n_content">
<item quantity="other">"新着のメッセージが%d 件あります。"</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 着信"</string>
<string name="notification_incoming_call">"📹 通話着信"</string>
<string name="notification_inline_reply_failed">"** 送信失敗 - ルームを開いてください"</string>
<string name="notification_invitation_action_join">"参加"</string>
<string name="notification_invitation_action_reject">"拒否"</string>
<plurals name="notification_invitations">
<item quantity="other">"%d 件の招待"</item>
</plurals>
<string name="notification_invite_body">"チャットにあなたを招待しました"</string>
<string name="notification_invite_body_with_sender">"%1$sがあなたをチャットに招待しました"</string>
<string name="notification_mentioned_you_body">"%1$s があなたをメンションしました"</string>
<string name="notification_new_messages">"新着メッセージ"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="other">"%d 件の新着メッセージ"</item>
</plurals>
<string name="notification_reaction_body">"%1$sへのリアクション"</string>
<string name="notification_room_action_mark_as_read">"既読にする"</string>
<string name="notification_room_action_quick_reply">"クイック返信"</string>
<string name="notification_room_invite_body">"ルームに招待されました"</string>
<string name="notification_room_invite_body_with_sender">"%1$s があなたをルームに招待しました"</string>
<string name="notification_sender_me">"自分"</string>
<string name="notification_sender_mention_reply">"%1$s がメンションまたは返信しました"</string>
<string name="notification_space_invite_body">"スペースに招待されました"</string>
<string name="notification_space_invite_body_with_sender">"%1$s があなたをスペースに招待しました"</string>
<string name="notification_test_push_notification_content">"通知を表示しています。タップしてください。"</string>
<string name="notification_thread_in_room">"%1$sのスレッド"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="other">"%d件の未読メッセージ"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$sと%2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%2$sに%1$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%2$sに%1$sと%3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="other">"%d 個のルーム"</item>
</plurals>
<string name="push_distributor_background_sync_android">"バックグラウンド同期"</string>
<string name="push_distributor_firebase_android">"Google サービス"</string>
<string name="push_no_valid_google_play_services_apk_android">"有効なGoogle Play 開発者サービスがありません。通知が正しく機能しない可能性があります。"</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"ブロックしたユーザーを確認中"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"ブロックしたユーザーを表示"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"ブロックしたユーザーはいません。"</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="other">"%1$d 人のユーザーをブロックしました。以降の通知を受信しません。"</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"ブロックしたユーザー"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"現在のプロバイダーの名前を取得してください。"</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"プッシュ通知プロバイダーが選択されていません。"</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_distributor_not_found">"現在のプッシュ通知プロバイダーは %1$s で、現在のプッシュ通知ディストリビューター は %2$s です。しかし、ディストリビューター %3$s は見つかりませんでした。アンインストールされている可能性があります。"</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure_no_distributor">"現在のプッシュ通知プロバイダーは %1$s ですが、ディストリビューターが設定されていません。"</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"現在のプッシュ通知プロバイダー: %1$s"</string>
<string name="troubleshoot_notifications_test_current_push_provider_success_with_distributor">"現在のプッシュ通知プロバイダー: %1$s (%2$s)"</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"現在のプッシュ通知プロバイダー"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"少なくとも一つ以上のプッシュ通知プロバイダーに、アプリケーションが対応していることを確認してください。"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"対応しているプッシュ通知プロバイダーが見つかりませんでした。"</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="other">"%1$d 個の通知プロバイダーを発見: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"このアプリケーションは %1$s に対応しています。"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"プッシュ通知プロバイダーへの対応状況"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"アプリケーションが通知を表示できることを確認してください。"</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"通知がタップされていません。"</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"通知を表示できません。"</string>
<string name="troubleshoot_notifications_test_display_notification_success">"通知がタップされました。"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"通知の表示"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"テストを続行するには、通知にタップしてください。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"プッシュ通知をアプリケーションが受信していることを確認してください。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"エラー: プッシュ通知プロバイダーがリクエストを拒否しました。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"エラー: %1$s"</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"エラー: プッシュ通知をテストできません。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"エラー: 通知の待機がタイムアウトしました。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"プッシュ通知のループバックに %1$d ms 要しました。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"プッシュ通知のループバックをテスト"</string>
</resources>

View file

@ -16,6 +16,7 @@
<plurals name="notification_fallback_n_content">
<item quantity="other">"%d개의 새 메시지가 있습니다."</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 수신 전화"</string>
<string name="notification_incoming_call">"📹 수신 전화"</string>
<string name="notification_inline_reply_failed">"** 전송 실패 - 방을 열여주세요"</string>
<string name="notification_invitation_action_join">"참가하기"</string>

View file

@ -51,7 +51,7 @@
<string name="notification_space_invite_body">"Пригласил(а) вас в пространство"</string>
<string name="notification_space_invite_body_with_sender">"%1$s пригласил(а) вас в пространство"</string>
<string name="notification_test_push_notification_content">"Вы просматриваете уведомление! Нажмите на меня!"</string>
<string name="notification_thread_in_room">"Ветка в %1$s"</string>
<string name="notification_thread_in_room">"Обсуждение в %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">

View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Gọi"</string>
<string name="notification_channel_listening_for_events">"Đang lắng nghe sự kiện"</string>
<string name="notification_channel_noisy">"Thông báo ồn ào"</string>
<string name="notification_channel_ringing_calls">"Cuộc gọi đang đổ chuông"</string>
<string name="notification_channel_silent">"Thông báo im lặng"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="other">"%1$s:%2$d tin nhắn"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="other">"%d thông báo"</item>
</plurals>
<string name="notification_fallback_content">"Bạn có tin nhắn mới."</string>
<string name="notification_incoming_call">"📹 Cuộc gọi đến"</string>
<string name="notification_inline_reply_failed">"** Không gửi được - vui lòng mở phòng"</string>
<string name="notification_invitation_action_join">"Tham gia"</string>
<string name="notification_invitation_action_reject">"Từ chối"</string>
<plurals name="notification_invitations">
<item quantity="other">"%d lời mời"</item>
</plurals>
<string name="notification_invite_body">"Đã mời bạn trò chuyện"</string>
<string name="notification_mentioned_you_body">"Đã nhắc đến bạn: %1$s"</string>
<string name="notification_new_messages">"Tin nhắn mới"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="other">"%dtin nhắn mới"</item>
</plurals>
<string name="notification_reaction_body">"Đã thả %1$s vào tin nhắn"</string>
<string name="notification_room_action_mark_as_read">"Đánh dấu đã đọc"</string>
<string name="notification_room_action_quick_reply">"Trả lời nhanh"</string>
<string name="notification_room_invite_body">"Đã mời bạn tham gia phòng"</string>
<string name="notification_sender_me">"Tôi"</string>
<string name="notification_test_push_notification_content">"Bạn đang xem thông báo! Bấm vào đây!"</string>
<string name="notification_ticker_text_dm">"%1$s:%2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="other">"%dtin nhắn chưa đọc đã thông báo"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s và %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s in %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s trong %2$s và %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="other">"%d phòng"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Đồng bộ hóa trong nền"</string>
<string name="push_distributor_firebase_android">"Dịch vụ của Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Không tìm thấy Dịch vụ Google Play hợp lệ. Thông báo có thể không hoạt động đúng cách."</string>
<string name="troubleshoot_notifications_test_blocked_users_title">"Người dùng bị chặn"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Hãy đảm bảo rằng ứng dụng hỗ trợ ít nhất một nhà cung cấp thông báo đẩy."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Không tìm thấy hỗ trợ từ nhà cung cấp thông báo đẩy."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="other">"Đã tìm thấy %1$d nhà cung cấp thông báo đẩy: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Hỗ trợ nhà cung cấp thông báo đẩy"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Kiểm tra xem ứng dụng có thể hiển thị thông báo hay không."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Thông báo chưa được nhấp vào."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Không thể hiển thị thông báo."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Thông báo đã được nhấp!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Hiển thị thông báo"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Hãy nhấp vào thông báo để tiếp tục thử nghiệm."</string>
</resources>

Some files were not shown because too many files have changed in this diff Show more