Merge branch 'develop' into feature/valere/call/decline_timeline_rendering

This commit is contained in:
Valere 2026-05-11 11:21:02 +02:00
commit a478d87fc3
995 changed files with 7864 additions and 3674 deletions

View file

@ -23,6 +23,13 @@ fun interface JsonProvider {
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultJsonProvider : JsonProvider {
private val json: Json by lazy { Json { ignoreUnknownKeys = true } }
private val json: Json by lazy {
Json {
ignoreUnknownKeys = true
allowComments = true
allowTrailingComma = true
}
}
override fun invoke() = json
}

View file

@ -11,7 +11,6 @@ package io.element.android.libraries.architecture
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
@ -88,11 +87,9 @@ inline fun <reified NavTarget : Any> BaseFlowNode<NavTarget>.OverlayView(
@Composable
inline fun <reified NavTarget : Any> BaseFlowNode<NavTarget>.BackstackWithOverlayBox(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit = {},
) {
Box(modifier = modifier) {
BackstackView()
OverlayView()
content()
}
}

View file

@ -0,0 +1,13 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.cachestore.api"
}

View file

@ -0,0 +1,13 @@
/*
* 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.cachestore.api
data class CacheData(
val value: String,
val updatedAt: Long,
)

View file

@ -0,0 +1,15 @@
/*
* 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.cachestore.api
interface CacheStore {
suspend fun storeData(key: String, data: CacheData)
suspend fun getData(key: String): CacheData?
suspend fun deleteData(key: String)
suspend fun deleteAll()
}

View file

@ -0,0 +1,48 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.sqldelight)
}
android {
namespace = "io.element.android.libraries.cachestore.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.encryptedDb)
api(projects.libraries.cachestore.api)
implementation(libs.sqldelight.driver.android)
implementation(libs.sqlcipher)
implementation(libs.sqlite)
implementation(projects.libraries.di)
implementation(libs.sqldelight.coroutines)
testCommonDependencies(libs)
testImplementation(libs.sqldelight.driver.jvm)
}
sqldelight {
databases {
create("CacheDatabase") {
// https://sqldelight.github.io/sqldelight/2.1.0/android_sqlite/migrations/
// To generate a .db file from your latest schema, run this task
// ./gradlew generateDebugCacheDatabaseSchema
// Test migration by running
// ./gradlew verifySqlDelightMigration
schemaOutputDirectory = File("src/main/sqldelight/databases")
verifyMigrations = true
}
}
}

View file

@ -0,0 +1,26 @@
/*
* 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.cachestore.impl
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.CacheData as DbCacheData
internal fun CacheData.toDbModel(key: String): DbCacheData {
return DbCacheData(
key = key,
value_ = value,
updatedAt = updatedAt,
)
}
internal fun DbCacheData.toApiModel(): CacheData {
return CacheData(
value = value_,
updatedAt = updatedAt,
)
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.cachestore.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.api.CacheStore
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DatabaseCacheStore(
private val database: CacheDatabase,
) : CacheStore {
override suspend fun getData(key: String): CacheData? {
return database.cacheDataQueries.selectData(key)
.executeAsOneOrNull()
?.toApiModel()
}
override suspend fun storeData(key: String, data: CacheData) {
database.cacheDataQueries.insertData(
data.toDbModel(key)
).await()
}
override suspend fun deleteData(key: String) {
database.cacheDataQueries.deleteData(key).await()
}
override suspend fun deleteAll() {
database.cacheDataQueries.deleteAll().await()
}
}

View file

@ -0,0 +1,43 @@
/*
* 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.cachestore.impl.di
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.cachestore.impl.CacheDatabase
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.encrypteddb.SqlCipherDriverFactory
import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider
@BindingContainer
@ContributesTo(AppScope::class)
object CacheStoreModule {
@Provides
@SingleIn(AppScope::class)
fun provideCacheDatabase(
@ApplicationContext context: Context,
): CacheDatabase {
val name = "cache_database"
val secretFile = context.getDatabasePath("$name.key")
// Make sure the parent directory of the key file exists, otherwise it will crash in older Android versions
val parentDir = secretFile.parentFile
if (parentDir != null && !parentDir.exists()) {
parentDir.mkdirs()
}
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile)
val driver = SqlCipherDriverFactory(passphraseProvider)
.create(CacheDatabase.Schema, "$name.db", context)
return CacheDatabase(driver)
}
}

View file

@ -0,0 +1,28 @@
--------------------------------------------------------------------
-- Current version of the DB is the highest value of filename
-- in the folder `sqldelight/databases`.
--
-- When upgrading the schema, you have to create a file .sqm in the
-- `sqldelight/databases` folder and run the following task to
-- generate a .db file using the latest schema
-- > ./gradlew generateDebugCacheDatabaseSchema
--------------------------------------------------------------------
CREATE TABLE CacheData (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL,
updatedAt INTEGER NOT NULL
);
selectData:
SELECT * FROM CacheData WHERE key = ?;
insertData:
INSERT OR REPLACE INTO CacheData VALUES ?;
deleteData:
DELETE FROM CacheData WHERE key = ?;
deleteAll:
DELETE FROM CacheData;

View file

@ -0,0 +1,86 @@
/*
* 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.sessionstorage.impl
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.impl.CacheDatabase
import io.element.android.libraries.cachestore.impl.DatabaseCacheStore
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import io.element.android.libraries.cachestore.CacheData as DbCacheData
private const val A_KEY = "aKey"
private const val A_DATA_1 = "aData1"
private const val A_DATA_2 = "aData2"
class DatabaseCacheStoreTest {
private lateinit var database: CacheDatabase
private lateinit var databaseCacheStore: DatabaseCacheStore
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setup() {
// Initialise in memory SQLite driver
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
CacheDatabase.Schema.create(driver)
database = CacheDatabase(driver)
databaseCacheStore = DatabaseCacheStore(
database = database,
)
}
@Test
fun `storeData persists the CacheData into the DB, deleteData deletes it`() = runTest {
// Assert that no data is stored for the key
assertThat(database.cacheDataQueries.selectData(A_KEY).executeAsOneOrNull()).isNull()
// Store data
databaseCacheStore.storeData(A_KEY, CacheData(A_DATA_1, 1))
assertThat(database.cacheDataQueries.selectData(A_KEY).executeAsOneOrNull()).isEqualTo(
DbCacheData(
key = A_KEY,
value_ = A_DATA_1,
updatedAt = 1,
)
)
// Update data
databaseCacheStore.storeData(A_KEY, CacheData(A_DATA_2, 2))
assertThat(database.cacheDataQueries.selectData(A_KEY).executeAsOneOrNull()).isEqualTo(
DbCacheData(
key = A_KEY,
value_ = A_DATA_2,
updatedAt = 2,
)
)
// Delete data
databaseCacheStore.deleteData(A_KEY)
assertThat(database.cacheDataQueries.selectData(A_KEY).executeAsOneOrNull()).isNull()
}
@Test
fun `deleteAll deletes all the data`() = runTest {
// Assert that no data is stored for the key
assertThat(database.cacheDataQueries.selectData(A_KEY).executeAsOneOrNull()).isNull()
// Store data
databaseCacheStore.storeData(A_KEY, CacheData(A_DATA_1, 1))
assertThat(database.cacheDataQueries.selectData(A_KEY).executeAsOneOrNull()).isEqualTo(
DbCacheData(
key = A_KEY,
value_ = A_DATA_1,
updatedAt = 1,
)
)
// Delete all data
databaseCacheStore.deleteAll()
assertThat(database.cacheDataQueries.selectData(A_KEY).executeAsOneOrNull()).isNull()
}
}

View file

@ -0,0 +1,21 @@
/*
* 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.sessionstorage.impl
import io.element.android.libraries.cachestore.CacheData
import java.util.Date
internal fun aCacheData(
key: String = "aKey",
value: String = "aValue",
updatedAt: Date = Date(),
) = CacheData(
key = key,
value_ = value,
updatedAt = updatedAt.time,
)

View file

@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.cachestore.test"
}
dependencies {
implementation(projects.libraries.cachestore.api)
}

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.sessionstorage.test
import io.element.android.libraries.cachestore.api.CacheData
fun aCacheData(
value: String = "aValue",
updatedAt: Long = 0,
) = CacheData(
value = value,
updatedAt = updatedAt,
)

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.sessionstorage.test
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.api.CacheStore
class InMemoryCacheStore(
initialData: Map<String, CacheData> = emptyMap(),
) : CacheStore {
val dataMap = initialData.toMutableMap()
override suspend fun storeData(key: String, data: CacheData) {
dataMap[key] = data
}
override suspend fun getData(key: String): CacheData? {
return dataMap[key]
}
override suspend fun deleteData(key: String) {
dataMap.remove(key)
}
override suspend fun deleteAll() {
dataMap.clear()
}
}

View file

@ -13,3 +13,7 @@ plugins {
android {
namespace = "io.element.android.libraries.cryptography.api"
}
dependencies {
implementation(libs.coroutines.core)
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.cryptography.api
import kotlinx.coroutines.flow.Flow
import javax.crypto.SecretKey
/**
@ -15,16 +16,18 @@ import javax.crypto.SecretKey
* Implementation should be able to store the generated key securely.
*/
interface SecretKeyRepository {
fun hasKey(alias: String): Flow<Boolean>
/**
* Get or create a secret key for a given alias.
* @param alias the alias to use
* @param requiresUserAuthentication true if the key should be protected by user authentication
*/
fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey
suspend fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey
/**
* Delete the secret key for a given alias.
* @param alias the alias to use
*/
fun deleteKey(alias: String)
suspend fun deleteKey(alias: String)
}

View file

@ -21,6 +21,7 @@ setupDependencyInjection()
dependencies {
implementation(projects.libraries.di)
implementation(libs.coroutines.core)
api(projects.libraries.cryptography.api)
testCommonDependencies(libs)

View file

@ -13,11 +13,16 @@ import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
import io.element.android.libraries.cryptography.api.SecretKeyRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber
import java.security.KeyStore
import java.security.KeyStoreException
import java.util.concurrent.ConcurrentHashMap
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
@ -25,13 +30,23 @@ import javax.crypto.SecretKey
* Default implementation of [SecretKeyRepository] that uses the Android Keystore to store the keys.
* The generated key uses AES algorithm, with a key size of 128 bits, and the GCM block mode.
*/
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class KeyStoreSecretKeyRepository(
private val keyStore: KeyStore,
) : SecretKeyRepository {
private val hasKeyMap = ConcurrentHashMap<String, MutableStateFlow<Boolean>>()
@Suppress("RunCatchingNotAllowed")
override fun hasKey(alias: String): Flow<Boolean> {
return hasKeyMap.getOrPut(alias) {
MutableStateFlow(runCatching { keyStore.containsAlias(alias) }.getOrDefault(false))
}.asStateFlow()
}
// False positive lint issue
@SuppressLint("WrongConstant")
override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
override suspend fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)
?.secretKey
return if (secretKeyEntry == null) {
@ -46,15 +61,22 @@ class KeyStoreSecretKeyRepository(
.setUserAuthenticationRequired(requiresUserAuthentication)
.build()
generator.init(keyGenSpec)
generator.generateKey()
generator.generateKey().also {
hasKeyMap.getOrPut(alias) {
MutableStateFlow(true)
}.emit(true)
}
} else {
secretKeyEntry
}
}
override fun deleteKey(alias: String) {
override suspend fun deleteKey(alias: String) {
try {
keyStore.deleteEntry(alias)
hasKeyMap.getOrPut(alias) {
MutableStateFlow(false)
}.emit(false)
} catch (e: KeyStoreException) {
Timber.e(e)
}

View file

@ -16,4 +16,5 @@ android {
dependencies {
api(projects.libraries.cryptography.api)
implementation(libs.coroutines.core)
}

View file

@ -10,20 +10,39 @@ package io.element.android.libraries.cryptography.test
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
import io.element.android.libraries.cryptography.api.SecretKeyRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
class SimpleSecretKeyRepository : SecretKeyRepository {
private var secretKeyForAlias = HashMap<String, SecretKey>()
override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
private val hasKeyMap = ConcurrentHashMap<String, MutableStateFlow<Boolean>>()
override fun hasKey(alias: String): Flow<Boolean> {
return hasKeyMap.getOrPut(alias) {
MutableStateFlow(false)
}.asStateFlow()
}
override suspend fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey {
return secretKeyForAlias.getOrPut(alias) {
generateKey()
generateKey().also {
hasKeyMap.getOrPut(alias) {
MutableStateFlow(true)
}.emit(true)
}
}
}
override fun deleteKey(alias: String) {
override suspend fun deleteKey(alias: String) {
secretKeyForAlias.remove(alias)
hasKeyMap.getOrPut(alias) {
MutableStateFlow(false)
}.emit(false)
}
private fun generateKey(): SecretKey {

View file

@ -1,5 +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_date_at_time">"%1$s %2$s"</string>
<string name="common_date_this_month">"本月"</string>
</resources>

View file

@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@ -148,7 +150,11 @@ private fun TitleAndDescription(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
color = titleColor,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.semantics {
heading()
},
)
if (trailingContent != null) {
Spacer(Modifier.width(12.dp))

View file

@ -54,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.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.utils.CommonDrawables
private val PIN_WIDTH = 42.dp
@ -395,7 +396,7 @@ private object LocationPinRenderer {
internal fun LocationPinPreview() = ElementPreview {
val sampleAvatarData = AvatarData(
id = "@alice:matrix.org",
name = "Alice",
name = USER_NAME_ALICE,
url = null,
size = AvatarSize.SelectedUser
)

View file

@ -8,10 +8,12 @@
package io.element.android.libraries.designsystem.components.avatar
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
fun anAvatarData(
// Let's the id not start with a 'a'.
// Let the id not start with a 'a'.
id: String = "@id_of_alice:server.org",
name: String? = "Alice",
name: String? = USER_NAME_ALICE,
url: String? = null,
size: AvatarSize = AvatarSize.RoomListItem,
) = AvatarData(

View file

@ -1,140 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
/** Ratio between the box size (120 on Figma) and the avatar size (75 on Figma). */
private const val SIZE_RATIO = 1.6f
/**
* https://www.figma.com/design/A2pAEvTEpJZBiOPUlcMnKi/Settings-%2B-Room-Details-(new)?node-id=1787-56333
*/
@Composable
fun DmAvatars(
userAvatarData: AvatarData,
otherUserAvatarData: AvatarData,
openAvatarPreview: (url: String) -> Unit,
openOtherAvatarPreview: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val boxSize = userAvatarData.size.dp * SIZE_RATIO
val boxSizePx = boxSize.toPx()
val otherAvatarRadius = otherUserAvatarData.size.dp.toPx() / 2
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Box(
modifier = modifier.size(boxSize),
) {
// Draw user avatar and cut top end corner
Avatar(
avatarData = userAvatarData,
avatarType = AvatarType.User,
contentDescription = stringResource(CommonStrings.a11y_your_avatar),
modifier = Modifier
.align(Alignment.BottomStart)
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
val xOffset = if (isRtl) {
size.width - boxSizePx + otherAvatarRadius
} else {
boxSizePx - otherAvatarRadius
}
drawCircle(
color = Color.Black,
center = Offset(
x = xOffset,
y = size.height - (boxSizePx - otherAvatarRadius),
),
radius = otherAvatarRadius / 0.9f,
blendMode = BlendMode.Clear,
)
}
.clip(CircleShape)
.clickable(
enabled = userAvatarData.url != null,
onClickLabel = stringResource(CommonStrings.action_view),
) {
userAvatarData.url?.let { openAvatarPreview(it) }
}
)
// Draw other user avatar
Avatar(
avatarData = otherUserAvatarData,
avatarType = AvatarType.User,
contentDescription = stringResource(CommonStrings.a11y_other_user_avatar),
modifier = Modifier
.align(Alignment.TopEnd)
.clip(CircleShape)
.clickable(
enabled = otherUserAvatarData.url != null,
onClickLabel = stringResource(CommonStrings.action_view),
) {
otherUserAvatarData.url?.let { openOtherAvatarPreview(it) }
}
.testTag(TestTags.memberDetailAvatar)
)
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun DmAvatarsPreview() = ElementThemedPreview {
val size = AvatarSize.DmCluster
DmAvatars(
userAvatarData = anAvatarData(
id = "Alice",
name = "Alice",
size = size,
),
otherUserAvatarData = anAvatarData(
id = "Bob",
name = "Bob",
size = size,
),
openAvatarPreview = {},
openOtherAvatarPreview = {},
)
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun DmAvatarsRtlPreview() {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
DmAvatarsPreview()
}
}

View file

@ -0,0 +1,27 @@
/*
* 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.preview
const val USER_NAME_ALICE = "Alice"
const val USER_NAME_BOB = "Bob"
const val USER_NAME_CHARLIE = "Charlie"
const val USER_NAME_CAROL = "Carol"
const val USER_NAME_DAVID = "David"
const val USER_NAME_EVE = "Eve"
const val USER_NAME_JOHN_DOE = "John Doe"
const val USER_NAME_JUSTIN = "Justin"
const val USER_NAME_MALLORY = "Mallory"
const val USER_NAME_SENDER = "Sender"
const val USER_NAME_SUSIE = "Susie"
const val USER_NAME_VICTOR = "Victor"
const val USER_NAME_WALTER = "Walter"
const val ROOM_NAME = "Room name"
const val SPACE_NAME = "Space name"
const val LAST_MESSAGE = "Last message"

View file

@ -17,6 +17,8 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@ -48,6 +50,9 @@ fun ListSectionHeader(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
modifier = Modifier.semantics {
heading()
},
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,

View file

@ -45,8 +45,8 @@
<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_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 置顶了 1 个消息"</string>

View file

@ -22,13 +22,6 @@ enum class FeatureFlags(
override val isFinished: Boolean,
override val isInLabs: Boolean = false,
) : Feature {
RoomDirectorySearch(
key = "feature.roomdirectorysearch",
title = "Room directory search",
description = "Allow user to search for public rooms in their homeserver",
defaultValue = { false },
isFinished = false,
),
ShowBlockedUsersDetails(
key = "feature.showBlockedUsersDetails",
title = "Show blocked users details",

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.NotJoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.spaces.SpaceService
@ -67,6 +68,7 @@ interface MatrixClient {
val sessionCoroutineScope: CoroutineScope
val ignoredUsersFlow: StateFlow<ImmutableList<UserId>>
val roomMembershipObserver: RoomMembershipObserver
val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate>
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
suspend fun getRoom(roomId: RoomId): BaseRoom?
suspend fun findDM(userId: UserId): Result<RoomId?>

View file

@ -0,0 +1,12 @@
/*
* 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.location
import io.element.android.libraries.matrix.api.core.EventId
typealias BeaconId = EventId

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.location
import io.element.android.libraries.matrix.api.core.RoomId
data class BeaconInfoUpdate(
val roomId: RoomId,
val beaconId: BeaconId,
val isLive: Boolean,
)

View file

@ -0,0 +1,14 @@
/*
* 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.location
sealed class LiveLocationException(message: String?) : Exception(message) {
class NotLive : LiveLocationException("The beacon event has expired.")
class Network : LiveLocationException("Network error")
class Other(val exception: Exception) : LiveLocationException(exception.message)
}

View file

@ -21,6 +21,8 @@ data class LiveLocationShare(
val startTimestamp: Long,
/** The timestamp when location sharing ends, in milliseconds. */
val endTimestamp: Long,
/** The event id from the beacon info. */
val beaconId: BeaconId
)
data class LastLocation(

View file

@ -19,4 +19,6 @@ data class RoomPowerLevelsValues(
val roomAvatar: Long,
val roomTopic: Long,
val spaceChild: Long,
val beacon: Long,
val beaconInfo: Long,
)

View file

@ -33,7 +33,7 @@ interface SessionVerificationService {
/**
* Request verification of the current session.
*/
suspend fun requestCurrentSessionVerification()
suspend fun requestDeviceVerification()
/**
* Request verification of the user with the given [userId].
@ -56,9 +56,9 @@ interface SessionVerificationService {
suspend fun declineVerification()
/**
* Starts the verification of the unverified session from another device.
* Transition the current verification request into a SAS verification flow.
*/
suspend fun startVerification()
suspend fun startSasVerification()
/**
* Returns the verification service state to the initial step.

View file

@ -31,6 +31,7 @@ dependencies {
implementation(projects.libraries.rustlsTls)
implementation(projects.appconfig)
implementation(projects.features.enterprise.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.di)
@ -49,6 +50,7 @@ dependencies {
implementation(libs.kotlinx.collections.immutable)
testCommonDependencies(libs)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)

View file

@ -70,6 +70,7 @@ import io.element.android.libraries.matrix.impl.room.RustRoomFactory
import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
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.location.map
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper
import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService
import io.element.android.libraries.matrix.impl.roomdirectory.map
@ -113,6 +114,8 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.AuthData
import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails
import org.matrix.rustcomponents.sdk.BeaconInfoListener
import org.matrix.rustcomponents.sdk.BeaconInfoUpdate
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientException
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
@ -207,6 +210,15 @@ class RustMatrixClient(
analyticsService = analyticsService,
)
override val ownBeaconInfoUpdates = mxCallbackFlow {
val listener = object : BeaconInfoListener {
override fun onUpdate(update: BeaconInfoUpdate) {
trySend(update.map())
}
}
innerClient.subscribeToOwnBeaconInfoUpdates(listener)
}
override val sessionVerificationService = RustSessionVerificationService(
client = innerClient,
isSyncServiceReady = syncService.syncState.map { it == SyncState.Running },

View file

@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.auth
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
@ -66,6 +67,7 @@ class RustMatrixAuthenticationService(
private val rustMatrixClientFactory: RustMatrixClientFactory,
private val passphraseGenerator: PassphraseGenerator,
private val oAuthConfigurationProvider: OAuthConfigurationProvider,
private val enterpriseService: EnterpriseService,
) : MatrixAuthenticationService {
// Any existing Element Classic session that we want to try to import secrets from during login.
private var elementClassicSession: ElementClassicSession? = null
@ -269,6 +271,12 @@ class RustMatrixAuthenticationService(
additionalScopes = emptyList(),
)
val url = oAuthAuthorizationData.loginUrl()
.let {
enterpriseService.tweakMasUrl(
url = it,
homeserver = client.server() ?: client.homeserver(),
)
}
pendingOAuthAuthorizationData = oAuthAuthorizationData
OAuthDetails(url)
}.mapFailure { failure ->

View file

@ -45,6 +45,7 @@ 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.liveLocationSharesFlow
import io.element.android.libraries.matrix.impl.room.location.map
import io.element.android.libraries.matrix.impl.room.location.timedByExpiry
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService
@ -72,6 +73,7 @@ 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.LiveLocationException
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
import org.matrix.rustcomponents.sdk.SendQueueListener
@ -525,12 +527,22 @@ class JoinedRustRoom(
override suspend fun stopLiveLocationShare(): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.stopLiveLocationShare()
}.mapFailure { throwable ->
when (throwable) {
is LiveLocationException -> throwable.map()
else -> throwable
}
}
}
override suspend fun sendLiveLocation(geoUri: String): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.sendLiveLocation(geoUri)
}.mapFailure { throwable ->
when (throwable) {
is LiveLocationException -> throwable.map()
else -> throwable
}
}
}

View file

@ -0,0 +1,21 @@
/*
* 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.location
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
import org.matrix.rustcomponents.sdk.BeaconInfoUpdate as RustBeaconInfoUpdate
fun RustBeaconInfoUpdate.map(): BeaconInfoUpdate {
return BeaconInfoUpdate(
roomId = RoomId(roomId),
beaconId = EventId(eventId),
isLive = live
)
}

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.impl.room.location
import io.element.android.libraries.matrix.api.room.location.LiveLocationException
import org.matrix.rustcomponents.sdk.LiveLocationException as RustLiveLocationException
fun RustLiveLocationException.map(): LiveLocationException {
return when (this) {
is RustLiveLocationException.Network -> LiveLocationException.Network()
is RustLiveLocationException.NotLive -> LiveLocationException.NotLive()
else -> LiveLocationException.Other(this)
}
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.room.location
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.location.LastLocation
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
@ -41,9 +42,9 @@ fun RoomInterface.liveLocationSharesFlow(): Flow<List<LiveLocationShare>> {
}
}
return callbackFlow {
val liveLocationShares = liveLocationsObserver()
val observer = liveLocationsObserver()
val shares: MutableList<LiveLocationShare> = ArrayList()
val taskHandle = liveLocationShares.subscribe(object : LiveLocationsListener {
val taskHandle = observer.subscribe(object : LiveLocationsListener {
override fun onUpdate(updates: List<LiveLocationShareUpdate>) {
for (update in updates) {
shares.applyUpdate(update)
@ -53,13 +54,14 @@ fun RoomInterface.liveLocationSharesFlow(): Flow<List<LiveLocationShare>> {
})
awaitClose {
taskHandle.cancelAndDestroy()
liveLocationShares.destroy()
observer.destroy()
}
}.buffer(Channel.UNLIMITED)
}
private fun RustLiveLocationShare.into(): LiveLocationShare {
return LiveLocationShare(
beaconId = EventId(beaconId),
userId = UserId(userId),
lastLocation = lastLocation?.let {
LastLocation(
@ -69,6 +71,6 @@ private fun RustLiveLocationShare.into(): LiveLocationShare {
)
},
startTimestamp = startTs.toLong(),
endTimestamp = (startTs + timeout).toLong()
endTimestamp = (startTs + timeout).toLong(),
)
}

View file

@ -26,6 +26,8 @@ object RoomPowerLevelsValuesMapper {
roomAvatar = values.roomAvatar,
roomTopic = values.roomTopic,
spaceChild = values.spaceChild,
beacon = values.beacon,
beaconInfo = values.beaconInfo,
)
}
}

View file

@ -124,7 +124,7 @@ class RustSessionVerificationService(
this.listener = listener
}
override suspend fun requestCurrentSessionVerification() = tryOrFail {
override suspend fun requestDeviceVerification() = tryOrFail {
ensureEncryptionIsInitialized()
verificationController.requestDeviceVerification()
currentVerificationRequest = VerificationRequest.Outgoing.CurrentSession
@ -146,7 +146,7 @@ class RustSessionVerificationService(
override suspend fun declineVerification() = tryOrFail { verificationController.declineVerification() }
override suspend fun startVerification() = tryOrFail {
override suspend fun startSasVerification() = tryOrFail {
verificationController.startSasVerification()
}

View file

@ -9,6 +9,8 @@
package io.element.android.libraries.matrix.impl.auth
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.matrix.impl.ClientBuilderProvider
import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider
import io.element.android.libraries.matrix.impl.createRustMatrixClientFactory
@ -50,6 +52,7 @@ class RustMatrixAuthenticationServiceTest {
private fun TestScope.createRustMatrixAuthenticationService(
sessionStore: SessionStore = InMemorySessionStore(),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
): RustMatrixAuthenticationService {
val baseDirectory = File("/base")
val cacheDirectory = File("/cache")
@ -68,6 +71,7 @@ class RustMatrixAuthenticationServiceTest {
buildMeta = aBuildMeta(),
oAuthRedirectUrlProvider = FakeOAuthRedirectUrlProvider(),
),
enterpriseService = enterpriseService,
)
}
}

View file

@ -67,6 +67,7 @@ internal fun aRustNotificationRoomInfo(
joinedMembersCount: ULong = 2u,
isEncrypted: Boolean? = true,
isDirect: Boolean = false,
isDm: Boolean = false,
joinRule: JoinRule? = null,
isSpace: Boolean = false,
serviceMembers: List<UserId> = emptyList(),
@ -79,6 +80,7 @@ internal fun aRustNotificationRoomInfo(
joinedMembersCount = joinedMembersCount,
isEncrypted = isEncrypted,
isDirect = isDirect,
isDm = isDm,
joinRule = joinRule,
isSpace = isSpace,
serviceMembers = serviceMembers.map { it.value },

View file

@ -63,6 +63,7 @@ internal fun aRustRoomInfo(
isLowPriority: Boolean = false,
activeRoomCallConsensusIntent: RtcCallIntentConsensus = RtcCallIntentConsensus.None,
activeServiceMembersCount: Int = 0,
isDm: Boolean = false,
) = RoomInfo(
id = id,
displayName = displayName,
@ -103,4 +104,5 @@ internal fun aRustRoomInfo(
isLowPriority = isLowPriority,
activeRoomCallConsensusIntent = activeRoomCallConsensusIntent,
activeServiceMembersCount = activeServiceMembersCount.toULong(),
isDm = isDm,
)

View file

@ -24,6 +24,7 @@ internal fun aRustRoomMember(
isIgnored: Boolean = false,
role: RoomMemberRole = RoomMemberRole.USER,
membershipChangeReason: String? = null,
isServiceMember: Boolean = false,
) = RoomMember(
userId = userId.value,
displayName = displayName,
@ -34,4 +35,5 @@ internal fun aRustRoomMember(
isIgnored = isIgnored,
suggestedRoleForPowerLevel = role,
membershipChangeReason = membershipChangeReason,
isServiceMember = isServiceMember,
)

View file

@ -22,6 +22,8 @@ internal fun aRustRoomPowerLevelsValues(
roomAvatar: Long,
roomTopic: Long,
spaceChild: Long,
beacon: Long,
beaconInfo: Long,
) = RoomPowerLevelsValues(
ban = ban,
invite = invite,
@ -33,5 +35,7 @@ internal fun aRustRoomPowerLevelsValues(
roomName = roomName,
roomAvatar = roomAvatar,
roomTopic = roomTopic,
spaceChild = spaceChild
spaceChild = spaceChild,
beacon = beacon,
beaconInfo = beaconInfo,
)

View file

@ -19,6 +19,7 @@ import org.matrix.rustcomponents.sdk.SpaceRoom
internal fun aRustSpaceRoom(
roomId: RoomId = A_ROOM_ID,
isDirect: Boolean = false,
isDm: Boolean = false,
canonicalAlias: String? = null,
rawName: String? = null,
displayName: String = "",
@ -35,6 +36,7 @@ internal fun aRustSpaceRoom(
) = SpaceRoom(
roomId = roomId.value,
isDirect = isDirect,
isDm = isDm,
canonicalAlias = canonicalAlias,
rawName = rawName,
displayName = displayName,

View file

@ -32,4 +32,6 @@ fun defaultFfiRoomPowerLevelValues() = RoomPowerLevelsValues(
roomTopic = 50,
spaceChild = 50,
usersDefault = 0,
beacon = 0,
beaconInfo = 0,
)

View file

@ -11,6 +11,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
@ -24,9 +25,9 @@ class TimedLiveLocationSharesFlowTest {
@Test
fun `it keeps emitting shares for subsequent expiries without upstream changes`() = runTest {
val shares = listOf(
aLiveLocationShare(userId = "@alice:server", endTimestamp = 1_000),
aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000),
aLiveLocationShare(userId = "@carol:server", endTimestamp = 3_000),
aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 1_000),
aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 2_000),
aLiveLocationShare(userId = UserId("@carol:server"), endTimestamp = 3_000),
)
flowOf(shares)
@ -56,8 +57,8 @@ class TimedLiveLocationSharesFlowTest {
@Test
fun `it does not double-emit when a share is already expired on receipt`() = runTest {
val shares = listOf(
aLiveLocationShare(userId = "@alice:server", endTimestamp = 500),
aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000),
aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 500),
aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 2_000),
)
flowOf(shares)
@ -81,8 +82,8 @@ class TimedLiveLocationSharesFlowTest {
val upstream = MutableSharedFlow<List<LiveLocationShare>>(extraBufferCapacity = 1)
val initialShares = listOf(aLiveLocationShare(endTimestamp = 10_000))
val updatedShares = listOf(
aLiveLocationShare(userId = "@alice:server", endTimestamp = 10_000),
aLiveLocationShare(userId = "@bob:server", endTimestamp = 6_000),
aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 10_000),
aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 6_000),
)
upstream
@ -133,15 +134,3 @@ class TimedLiveLocationSharesFlowTest {
}
}
}
private fun aLiveLocationShare(
userId: String = "@user:server",
endTimestamp: Long,
): LiveLocationShare {
return LiveLocationShare(
userId = UserId(userId),
lastLocation = null,
startTimestamp = 0L,
endTimestamp = endTimestamp,
)
}

View file

@ -30,6 +30,8 @@ class RoomPowerLevelsValuesMapperTest {
roomAvatar = 9,
roomTopic = 10,
spaceChild = 11,
beacon = 12,
beaconInfo = 13,
)
)
).isEqualTo(
@ -44,6 +46,8 @@ class RoomPowerLevelsValuesMapperTest {
roomAvatar = 9,
roomTopic = 10,
spaceChild = 11,
beacon = 12,
beaconInfo = 13,
)
)
}

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.NotJoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.spaces.SpaceService
@ -107,6 +108,7 @@ class FakeMatrixClient(
private val canReportRoomLambda: () -> Boolean = { false },
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
override val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate> = emptyFlow(),
private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() },
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) },
private val getRecentEmojisLambda: () -> Result<List<String>> = { Result.success(emptyList()) },

View file

@ -223,4 +223,6 @@ fun defaultRoomPowerLevelValues() = RoomPowerLevelsValues(
roomAvatar = 50,
roomTopic = 50,
spaceChild = 50,
beacon = 0,
beaconInfo = 0,
)

View file

@ -89,7 +89,7 @@ class FakeJoinedRoom(
private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() },
private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> },
private val liveLocationSharesFlow: Flow<List<LiveLocationShare>> = MutableStateFlow(emptyList()),
private val startLiveLocationShareResult: (Long) -> Result<Unit> = { lambdaError() },
private val startLiveLocationShareResult: (Long) -> Result<EventId> = { lambdaError() },
private val stopLiveLocationShareResult: () -> Result<Unit> = { lambdaError() },
private val sendLiveLocationResult: (String) -> Result<Unit> = { lambdaError() },
) : JoinedRoom, BaseRoom by baseRoom {

View file

@ -0,0 +1,38 @@
/*
* 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.location
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.location.AssetType
import io.element.android.libraries.matrix.api.room.location.LastLocation
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
fun aLiveLocationShare(
beaconId: EventId = AN_EVENT_ID,
userId: UserId = A_USER_ID,
geoUri: String = "geo:48.8584,2.2945",
timestamp: Long = 0L,
startTimestamp: Long = 0L,
endTimestamp: Long = Long.MAX_VALUE,
assetType: AssetType = AssetType.SENDER,
): LiveLocationShare {
return LiveLocationShare(
beaconId = beaconId,
userId = userId,
lastLocation = LastLocation(
geoUri = geoUri,
timestamp = timestamp,
assetType = assetType,
),
startTimestamp = startTimestamp,
endTimestamp = endTimestamp,
)
}

View file

@ -22,12 +22,12 @@ import kotlinx.coroutines.flow.StateFlow
class FakeSessionVerificationService(
initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown,
private val requestCurrentSessionVerificationLambda: () -> Unit = { lambdaError() },
private val requestDeviceVerificationLambda: () -> Unit = { lambdaError() },
private val requestUserVerificationLambda: (UserId) -> Unit = { lambdaError() },
private val cancelVerificationLambda: () -> Unit = { lambdaError() },
private val approveVerificationLambda: () -> Unit = { lambdaError() },
private val declineVerificationLambda: () -> Unit = { lambdaError() },
private val startVerificationLambda: () -> Unit = { lambdaError() },
private val startSasVerificationLambda: () -> Unit = { lambdaError() },
private val resetLambda: (Boolean) -> Unit = { lambdaError() },
private val acknowledgeVerificationRequestLambda: (VerificationRequest.Incoming) -> Unit = { lambdaError() },
private val acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
@ -40,31 +40,31 @@ class FakeSessionVerificationService(
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
override val needsSessionVerification: Flow<Boolean> = _needsSessionVerification
override suspend fun requestCurrentSessionVerification() {
requestCurrentSessionVerificationLambda()
override suspend fun requestDeviceVerification() = simulateLongTask {
requestDeviceVerificationLambda()
}
override suspend fun requestUserVerification(userId: UserId) {
override suspend fun requestUserVerification(userId: UserId) = simulateLongTask {
requestUserVerificationLambda(userId)
}
override suspend fun cancelVerification() {
override suspend fun cancelVerification() = simulateLongTask {
cancelVerificationLambda()
}
override suspend fun approveVerification() {
override suspend fun approveVerification() = simulateLongTask {
approveVerificationLambda()
}
override suspend fun declineVerification() {
override suspend fun declineVerification() = simulateLongTask {
declineVerificationLambda()
}
override suspend fun startVerification() {
startVerificationLambda()
override suspend fun startSasVerification() = simulateLongTask {
startSasVerificationLambda()
}
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) = simulateLongTask {
resetLambda(cancelAnyPendingVerificationAttempt)
}
@ -75,7 +75,7 @@ class FakeSessionVerificationService(
this.listener = listener
}
override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) {
override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) = simulateLongTask {
acknowledgeVerificationRequestLambda(verificationRequest)
}

View file

@ -22,6 +22,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.InviteSender
@ -57,10 +58,10 @@ internal fun InviteSenderViewPreview() = ElementPreview {
InviteSenderView(
inviteSender = InviteSender(
userId = UserId("@bob:example.com"),
displayName = "Bob",
displayName = USER_NAME_BOB,
avatarData = AvatarData(
id = "@bob:example.com",
name = "Bob",
name = USER_NAME_BOB,
url = null,
size = AvatarSize.InviteSender,
),

View file

@ -9,6 +9,17 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.preview.USER_NAME_CAROL
import io.element.android.libraries.designsystem.preview.USER_NAME_DAVID
import io.element.android.libraries.designsystem.preview.USER_NAME_EVE
import io.element.android.libraries.designsystem.preview.USER_NAME_JOHN_DOE
import io.element.android.libraries.designsystem.preview.USER_NAME_JUSTIN
import io.element.android.libraries.designsystem.preview.USER_NAME_MALLORY
import io.element.android.libraries.designsystem.preview.USER_NAME_SUSIE
import io.element.android.libraries.designsystem.preview.USER_NAME_VICTOR
import io.element.android.libraries.designsystem.preview.USER_NAME_WALTER
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -23,30 +34,30 @@ open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
open class MatrixUserWithAvatarProvider : PreviewParameterProvider<MatrixUser?> {
override val values: Sequence<MatrixUser?>
get() = sequenceOf(
aMatrixUser(displayName = "John Doe"),
aMatrixUser(displayName = "John Doe", avatarUrl = "anUrl"),
aMatrixUser(displayName = USER_NAME_JOHN_DOE),
aMatrixUser(displayName = USER_NAME_JOHN_DOE, avatarUrl = "anUrl"),
)
}
fun aMatrixUser(
id: String = "@id_of_alice:server.org",
displayName: String? = "Alice",
id: String? = null,
displayName: String? = USER_NAME_ALICE,
avatarUrl: String? = null,
) = MatrixUser(
userId = UserId(id),
userId = UserId(id ?: "@${displayName?.lowercase()?.replace(" ", "_") ?: "id"}:server.org"),
displayName = displayName,
avatarUrl = avatarUrl,
)
fun aMatrixUserList() = listOf(
aMatrixUser("@alice:server.org", "Alice"),
aMatrixUser("@bob:server.org", "Bob"),
aMatrixUser("@carol:server.org", "Carol"),
aMatrixUser("@david:server.org", "David"),
aMatrixUser("@eve:server.org", "Eve"),
aMatrixUser("@justin:server.org", "Justin"),
aMatrixUser("@mallory:server.org", "Mallory"),
aMatrixUser("@susie:server.org", "Susie"),
aMatrixUser("@victor:server.org", "Victor"),
aMatrixUser("@walter:server.org", "Walter"),
aMatrixUser(displayName = USER_NAME_ALICE),
aMatrixUser(displayName = USER_NAME_BOB),
aMatrixUser(displayName = USER_NAME_CAROL),
aMatrixUser(displayName = USER_NAME_DAVID),
aMatrixUser(displayName = USER_NAME_EVE),
aMatrixUser(displayName = USER_NAME_JUSTIN),
aMatrixUser(displayName = USER_NAME_MALLORY),
aMatrixUser(displayName = USER_NAME_SUSIE),
aMatrixUser(displayName = USER_NAME_VICTOR),
aMatrixUser(displayName = USER_NAME_WALTER),
)

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.SPACE_NAME
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2048&m=dev
@ -72,7 +73,7 @@ internal fun OrganizationHeaderPreview() = ElementPreview {
url = "anUrl",
size = AvatarSize.OrganizationHeader,
),
name = "Space name",
name = SPACE_NAME,
numberOfSpaces = 9,
numberOfRooms = 88,
)

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_JOHN_DOE
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
@ -58,7 +59,7 @@ internal fun SelectedUserRtlPreview() = CompositionLocalProvider(
) {
ElementPreview {
SelectedUser(
matrixUser = aMatrixUser(displayName = "John Doe"),
matrixUser = aMatrixUser(displayName = USER_NAME_JOHN_DOE),
canRemove = true,
onUserRemove = {},
)

View file

@ -30,6 +30,11 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.SPACE_NAME
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.preview.USER_NAME_CHARLIE
import io.element.android.libraries.designsystem.preview.USER_NAME_DAVID
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -115,15 +120,15 @@ internal fun SpaceHeaderViewPreview() = ElementPreview {
size = AvatarSize.SpaceHeader,
),
alias = RoomAlias("#spaceAlias:matrix.org"),
name = "Space name",
name = SPACE_NAME,
topic = "Space topic: " + LoremIpsum(40).values.first(),
topicMaxLines = 2,
visibility = SpaceRoomVisibility.Public,
heroes = persistentListOf(
aMatrixUser(id = "@1:d", displayName = "Alice", avatarUrl = "aUrl"),
aMatrixUser(id = "@2:d", displayName = "Bob"),
aMatrixUser(id = "@3:d", displayName = "Charlie", avatarUrl = "aUrl"),
aMatrixUser(id = "@4:d", displayName = "Dave"),
aMatrixUser(id = "@1:d", displayName = USER_NAME_ALICE, avatarUrl = "aUrl"),
aMatrixUser(id = "@2:d", displayName = USER_NAME_BOB),
aMatrixUser(id = "@3:d", displayName = USER_NAME_CHARLIE, avatarUrl = "aUrl"),
aMatrixUser(id = "@4:d", displayName = USER_NAME_DAVID),
),
numberOfMembers = 999,
)

View file

@ -22,6 +22,10 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.preview.USER_NAME_CHARLIE
import io.element.android.libraries.designsystem.preview.USER_NAME_DAVID
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -98,10 +102,10 @@ internal fun SpaceMembersViewPreview() = ElementPreview(
) {
SpaceMembersView(
heroes = persistentListOf(
aMatrixUser(id = "@1:d", displayName = "Alice", avatarUrl = "aUrl"),
aMatrixUser(id = "@2:d", displayName = "Bob"),
aMatrixUser(id = "@3:d", displayName = "Charlie", avatarUrl = "aUrl"),
aMatrixUser(id = "@4:d", displayName = "Dave"),
aMatrixUser(id = "@1:d", displayName = USER_NAME_ALICE, avatarUrl = "aUrl"),
aMatrixUser(id = "@2:d", displayName = USER_NAME_BOB),
aMatrixUser(id = "@3:d", displayName = USER_NAME_CHARLIE, avatarUrl = "aUrl"),
aMatrixUser(id = "@4:d", displayName = USER_NAME_DAVID),
),
numberOfMembers = 123,
)

View file

@ -9,6 +9,8 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.preview.SPACE_NAME
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomType
@ -28,10 +30,10 @@ class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> {
state = CurrentUserMembership.LEFT,
),
aSpaceRoom(
displayName = "Alice",
displayName = SPACE_NAME,
roomType = RoomType.Room,
isDirect = true,
heroes = listOf(aMatrixUser(displayName = "Alice")),
heroes = listOf(aMatrixUser(displayName = USER_NAME_ALICE)),
state = CurrentUserMembership.JOINED,
numJoinedMembers = 2,
),
@ -69,9 +71,9 @@ class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> {
state = CurrentUserMembership.INVITED,
),
aSpaceRoom(
displayName = "Alice",
displayName = SPACE_NAME,
roomType = RoomType.Space,
heroes = listOf(aMatrixUser(displayName = "Alice")),
heroes = listOf(aMatrixUser(displayName = USER_NAME_ALICE)),
state = CurrentUserMembership.JOINED,
numJoinedMembers = 2,
),

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.ui.messages.reply
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.preview.USER_NAME_SENDER
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.media.MediaSource
@ -159,7 +160,7 @@ private fun aInReplyToDetails(
)
fun aProfileDetailsReady(
displayName: String? = "Sender",
displayName: String? = USER_NAME_SENDER,
displayNameAmbiguous: Boolean = false,
avatarUrl: String? = null,
) = ProfileDetails.Ready(

View file

@ -3,5 +3,7 @@
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Send invitation"</string>
<string name="screen_bottom_sheet_create_dm_message">"Kunne du tænke dig at starte en samtale med %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Send invitation?"</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_content">"Du har i øjeblikket ingen samtaler med denne person. Bekræft invitationen, før du fortsætter."</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_title">"Vil du starte en chat med denne nye kontakt?"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s ) inviterede dig"</string>
</resources>

View file

@ -3,5 +3,7 @@
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Invia invito"</string>
<string name="screen_bottom_sheet_create_dm_message">"Vuoi iniziare una conversazione con%1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Inviare invito?"</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_content">"Al momento non hai alcuna conversazione con questa persona. Conferma l\'invito prima di continuare."</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_title">"Vuoi avviare una conversazione con questo nuovo contatto?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) ti ha invitato"</string>
</resources>

View file

@ -3,5 +3,7 @@
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Wyślij zaproszenie"</string>
<string name="screen_bottom_sheet_create_dm_message">"Czy chcesz rozpocząć czat z %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Wysłać zaproszenie?"</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_content">"Obecnie nie posiadasz żadnych czatów z tą osobą. Potwierdź zaproszenie, zanim przejdziesz dalej."</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_title">"Rozpocząć czat z nowym kontaktem?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) zaprosił Cię"</string>
</resources>

View file

@ -3,5 +3,7 @@
<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

@ -5,5 +5,5 @@
<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>
<string name="screen_invites_invited_you">"%1$s%2$s邀请了你"</string>
</resources>

View file

@ -17,6 +17,7 @@ import kotlinx.parcelize.Parcelize
data class MediaInfo(
val filename: String,
val caption: String?,
val formattedCaption: CharSequence? = null,
val mimeType: String,
val fileSize: Long?,
val formattedFileSize: String,
@ -33,6 +34,7 @@ data class MediaInfo(
fun anImageMediaInfo(
senderId: UserId? = UserId("@alice:server.org"),
caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
@ -40,6 +42,7 @@ fun anImageMediaInfo(
filename = "an image file.jpg",
fileSize = 4 * 1024 * 1024,
caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Jpeg,
formattedFileSize = "4MB",
fileExtension = "jpg",
@ -54,6 +57,7 @@ fun anImageMediaInfo(
fun aVideoMediaInfo(
caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
@ -62,6 +66,7 @@ fun aVideoMediaInfo(
filename = "a video file.mp4",
fileSize = 14 * 1024 * 1024,
caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Mp4,
formattedFileSize = "14MB",
fileExtension = "mp4",
@ -77,6 +82,7 @@ fun aVideoMediaInfo(
fun aPdfMediaInfo(
filename: String = "a pdf file.pdf",
caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
@ -84,6 +90,7 @@ fun aPdfMediaInfo(
filename = filename,
fileSize = 23 * 1024 * 1024,
caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Pdf,
formattedFileSize = "23MB",
fileExtension = "pdf",
@ -105,6 +112,7 @@ fun anApkMediaInfo(
filename = "an apk file.apk",
fileSize = 50 * 1024 * 1024,
caption = null,
formattedCaption = null,
mimeType = MimeTypes.Apk,
formattedFileSize = "50MB",
fileExtension = "apk",
@ -120,6 +128,7 @@ fun anApkMediaInfo(
fun anAudioMediaInfo(
filename: String = "an audio file.mp3",
caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
@ -129,6 +138,7 @@ fun anAudioMediaInfo(
filename = filename,
fileSize = 7 * 1024 * 1024,
caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Mp3,
formattedFileSize = "7MB",
fileExtension = "mp3",
@ -144,6 +154,7 @@ fun anAudioMediaInfo(
fun aVoiceMediaInfo(
filename: String = "a voice file.ogg",
caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
@ -153,6 +164,7 @@ fun aVoiceMediaInfo(
filename = filename,
fileSize = 3 * 1024 * 1024,
caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Ogg,
formattedFileSize = "3MB",
fileExtension = "ogg",
@ -168,6 +180,7 @@ fun aVoiceMediaInfo(
fun aTxtMediaInfo(
filename: String = "a text file.txt",
caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
@ -175,6 +188,7 @@ fun aTxtMediaInfo(
filename = filename,
fileSize = 2 * 1024,
caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.PlainText,
formattedFileSize = "2kB",
fileExtension = "txt",

View file

@ -25,6 +25,9 @@ android {
setupDependencyInjection()
dependencies {
implementation(libs.matrix.richtexteditor.compose)
implementation(libs.matrix.richtexteditor)
implementation(projects.libraries.textcomposer.impl)
implementation(libs.coroutines.core)
implementation(libs.coil.compose)
implementation(libs.androidx.media3.exoplayer)

View file

@ -32,6 +32,7 @@ class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
filename = filename,
fileSize = null,
caption = null,
formattedCaption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = "",

View file

@ -98,6 +98,7 @@ class EventItemFactory(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@ -118,6 +119,7 @@ class EventItemFactory(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@ -139,6 +141,7 @@ class EventItemFactory(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@ -160,6 +163,7 @@ class EventItemFactory(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@ -181,6 +185,7 @@ class EventItemFactory(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@ -202,6 +207,7 @@ class EventItemFactory(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.mediaviewer.impl.details
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.MediaInfo
@ -26,7 +27,7 @@ open class MediaBottomSheetStateDeleteConfirmationProvider : PreviewParameterPro
fun aMediaBottomSheetStateDeleteConfirmation(
mediaInfo: MediaInfo = anImageMediaInfo(
senderName = "Alice",
senderName = USER_NAME_ALICE,
),
thumbnailSource: MediaSource? = null,
) = MediaBottomSheetState.DeleteConfirmation(

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.mediaviewer.impl.details
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
@ -35,7 +36,7 @@ fun aMediaBottomSheetStateDetails(
eventId: EventId? = EventId($$"$eventId"),
canDelete: Boolean = true,
mediaInfo: MediaInfo = anImageMediaInfo(
senderName = "Alice",
senderName = USER_NAME_ALICE,
dateSentFull = "December 6, 2024 at 12:59",
),
) = MediaBottomSheetState.Details(

View file

@ -11,6 +11,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.aMediaBottomSheetStateDetails
@ -112,7 +113,7 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
}
private fun aMediaGalleryState(
roomName: String = "Room name",
roomName: String = ROOM_NAME,
mode: MediaGalleryMode = MediaGalleryMode.Images,
groupedMediaItems: AsyncData<GroupedMediaItems> = AsyncData.Uninitialized,
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,

View file

@ -97,6 +97,7 @@ class AndroidLocalMediaFactory(
filename = fileName,
fileSize = fileSize,
caption = caption,
formattedCaption = null,
formattedFileSize = calculatedFormattedFileSize,
fileExtension = fileExtension,
senderId = senderId,

View file

@ -12,13 +12,12 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -28,7 +27,6 @@ import androidx.compose.runtime.rememberUpdatedState
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -91,33 +89,31 @@ fun MediaPlayerControllerView(
.widthIn(max = 480.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val bgColor = if (state.isPlaying) {
ElementTheme.colors.bgCanvasDefault
val colors = if (state.isPlaying) {
IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.bgCanvasDefault,
contentColor = ElementTheme.colors.iconPrimary,
)
} else {
ElementTheme.colors.textPrimary
IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.iconPrimary,
contentColor = ElementTheme.colors.iconOnSolidPrimary,
)
}
Box(
IconButton(
modifier = Modifier
.size(36.dp)
.background(
color = bgColor,
shape = CircleShape,
)
.clip(CircleShape)
.clickable { onTogglePlay() }
.padding(8.dp),
contentAlignment = Alignment.Center,
.size(36.dp),
onClick = onTogglePlay,
colors = colors,
) {
if (state.isPlaying) {
Icon(
imageVector = CompoundIcons.PauseSolid(),
tint = ElementTheme.colors.iconPrimary,
contentDescription = stringResource(CommonStrings.a11y_pause)
)
} else {
Icon(
imageVector = CompoundIcons.PlaySolid(),
tint = ElementTheme.colors.iconOnSolidPrimary,
contentDescription = stringResource(CommonStrings.a11y_play)
)
}

View file

@ -122,25 +122,11 @@ class MediaViewerDataSource(
*/
private fun buildMediaViewerPageList(groupedItems: List<MediaItem>) = buildList {
// Filter out DateSeparator items, we do not need them for the media viewer
val itemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator }
// Separate loading indicators and media events
val loadingIndicators = itemsNoDateSeparator.filterIsInstance<MediaItem.LoadingIndicator>()
val mediaEvents = itemsNoDateSeparator.filterIsInstance<MediaItem.Event>()
// Determine backward and forward loading indicators
val backwardLoading = loadingIndicators.find { it.direction == Timeline.PaginationDirection.BACKWARDS }
val forwardLoading = loadingIndicators.find { it.direction == Timeline.PaginationDirection.FORWARDS }
// Build ordered list: backward loading, media events (oldest first), forward loading
// Media events are currently newest first, reverse to get oldest first
val orderedEvents = mediaEvents.reversed()
// Create new list of MediaItem in order: backwardLoading, orderedEvents, forwardLoading
val orderedItems = buildList {
backwardLoading?.let { add(it) }
addAll(orderedEvents)
forwardLoading?.let { add(it) }
}
pagerKeysHandler.accept(orderedItems)
orderedItems.forEach { mediaItem ->
val groupedItemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator }
pagerKeysHandler.accept(groupedItemsNoDateSeparator)
groupedItemsNoDateSeparator.forEach { mediaItem ->
when (mediaItem) {
is MediaItem.DateSeparator -> Unit
is MediaItem.Event -> {
val sourceUrl = mediaItem.mediaSource().safeUrl
val localMedia = localMediaStates.getOrPut(sourceUrl) {
@ -164,7 +150,6 @@ class MediaViewerDataSource(
pagerKey = pagerKeysHandler.getKey(mediaItem),
)
)
is MediaItem.DateSeparator -> Unit // already filtered out
}
}
}.toImmutableList()

View file

@ -177,21 +177,18 @@ class MediaViewerPresenter(
currentIndex: IntState,
data: State<ImmutableList<MediaViewerPageData>>,
) {
// With newest-first ordering, backward loading indicator is at the last index
val isRenderingLoadingBackward by remember {
derivedStateOf {
currentIndex.intValue == 0 &&
currentIndex.intValue == data.value.lastIndex &&
data.value.size > 1 &&
data.value.firstOrNull() is MediaViewerPageData.Loading &&
(data.value.firstOrNull() as? MediaViewerPageData.Loading)?.direction == Timeline.PaginationDirection.BACKWARDS
data.value.lastOrNull() is MediaViewerPageData.Loading
}
}
if (isRenderingLoadingBackward) {
LaunchedEffect(Unit) {
// Observe the loading data vanishing
snapshotFlow {
val first = data.value.firstOrNull()
first is MediaViewerPageData.Loading && first.direction == Timeline.PaginationDirection.BACKWARDS
}
snapshotFlow { data.value.lastOrNull() is MediaViewerPageData.Loading }
.distinctUntilChanged()
.filter { !it }
.onEach { showNoMoreItemsSnackbar() }
@ -205,21 +202,18 @@ class MediaViewerPresenter(
currentIndex: IntState,
data: State<ImmutableList<MediaViewerPageData>>,
) {
// With newest-first ordering, forward loading indicator is at the first index
val isRenderingLoadingForward by remember {
derivedStateOf {
currentIndex.intValue == data.value.lastIndex &&
currentIndex.intValue == 0 &&
data.value.size > 1 &&
data.value.lastOrNull() is MediaViewerPageData.Loading &&
(data.value.lastOrNull() as? MediaViewerPageData.Loading)?.direction == Timeline.PaginationDirection.FORWARDS
data.value.firstOrNull() is MediaViewerPageData.Loading
}
}
if (isRenderingLoadingForward) {
LaunchedEffect(Unit) {
// Observe the loading data vanishing
snapshotFlow {
val last = data.value.lastOrNull()
last is MediaViewerPageData.Loading && last.direction == Timeline.PaginationDirection.FORWARDS
}
snapshotFlow { data.value.firstOrNull() is MediaViewerPageData.Loading }
.distinctUntilChanged()
.filter { !it }
.onEach { showNoMoreItemsSnackbar() }

View file

@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
@ -179,7 +180,7 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
)
),
anImageMediaInfo(
senderName = "Alice",
senderName = USER_NAME_ALICE,
dateSent = "21 NOV, 2024",
caption = LONG_CAPTION,
).let {
@ -194,6 +195,86 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
)
)
},
anImageMediaInfo(
senderName = "Bob",
dateSent = "22 NOV, 2024",
formattedCaption = "This is a <strong>bold</strong> caption",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
anImageMediaInfo(
senderName = "Charlie",
dateSent = "23 NOV, 2024",
formattedCaption = "This is an <em>italic</em> caption",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
anImageMediaInfo(
senderName = "Diana",
dateSent = "24 NOV, 2024",
formattedCaption = "This is a <code>code</code> caption",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
anImageMediaInfo(
senderName = "Eve",
dateSent = "25 NOV, 2024",
formattedCaption = "<blockquote>This is a quote caption</blockquote>",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
anImageMediaInfo(
senderName = "Frank",
dateSent = "26 NOV, 2024",
formattedCaption = "This caption has <strong>bold</strong>, <em>italic</em>, and <code>code</code> formatting.",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
)
}

View file

@ -31,8 +31,11 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -59,10 +62,12 @@ 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
import androidx.core.text.toSpannable
import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.androidutils.text.safeLinkify
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
@ -91,7 +96,9 @@ import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText
import kotlinx.coroutines.delay
import me.saket.telephoto.zoomable.OverzoomEffect
import me.saket.telephoto.zoomable.ZoomSpec
@ -120,6 +127,52 @@ fun MediaViewerView(
Scaffold(
modifier,
containerColor = Color.Transparent,
topBar = {
AnimatedVisibility(
visible = showOverlay,
enter = fadeIn(),
exit = fadeOut(),
) {
when (currentData) {
is MediaViewerPageData.MediaViewerData -> {
MediaViewerTopBar(
data = currentData,
canShowInfo = state.canShowInfo,
onBackClick = onBackClick,
onShareClick = {
state.eventSink(MediaViewerEvent.Share(currentData))
},
onSaveClick = {
state.eventSink(MediaViewerEvent.SaveOnDisk(currentData))
},
onInfoClick = {
state.eventSink(MediaViewerEvent.OpenInfo(currentData))
},
)
}
else -> {
TopAppBar(
title = {
if (currentData is MediaViewerPageData.Loading) {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(id = CommonStrings.common_loading_more),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = bgCanvasWithTransparency,
),
navigationIcon = { BackButton(onClick = onBackClick) },
)
}
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
val pagerState = rememberPagerState(state.currentIndex, 0f) {
@ -136,6 +189,7 @@ fun MediaViewerView(
// Pre-load previous and next pages
beyondViewportPageCount = 1,
key = { index -> state.listData[index].pagerKey },
reverseLayout = true,
) { page ->
when (val dataForPage = state.listData[page]) {
is MediaViewerPageData.Failure -> {
@ -186,69 +240,23 @@ fun MediaViewerView(
isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId,
)
// Bottom bar
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier.fillMaxSize()
) {
MediaViewerBottomBar(
modifier = Modifier.align(Alignment.BottomCenter),
showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(),
caption = dataForPage.mediaInfo.caption,
onHeightChange = { bottomPaddingInPixels = it },
)
}
AnimatedVisibility(
visible = showOverlay,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter),
) {
MediaViewerBottomBar(
showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(),
caption = dataForPage.mediaInfo.caption,
formattedCaption = dataForPage.mediaInfo.formattedCaption,
onHeightChange = { bottomPaddingInPixels = it },
)
}
}
}
}
}
// Top bar
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
) {
when (currentData) {
is MediaViewerPageData.MediaViewerData -> {
MediaViewerTopBar(
data = currentData,
canShowInfo = state.canShowInfo,
onBackClick = onBackClick,
onShareClick = {
state.eventSink(MediaViewerEvent.Share(currentData))
},
onSaveClick = {
state.eventSink(MediaViewerEvent.SaveOnDisk(currentData))
},
onInfoClick = {
state.eventSink(MediaViewerEvent.OpenInfo(currentData))
},
)
}
else -> {
TopAppBar(
title = {
if (currentData is MediaViewerPageData.Loading) {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(id = CommonStrings.common_loading_more),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = bgCanvasWithTransparency,
),
navigationIcon = { BackButton(onClick = onBackClick) },
)
}
}
}
}
}
when (val bottomSheetState = state.mediaBottomSheetState) {
@ -373,11 +381,12 @@ private fun MediaViewerPage(
isUserSelected = isUserSelected,
audioFocus = audioFocus,
)
ThumbnailView(
mediaInfo = data.mediaInfo,
thumbnailSource = data.thumbnailSource,
isVisible = showThumbnail,
)
if (showThumbnail) {
ThumbnailView(
mediaInfo = data.mediaInfo,
thumbnailSource = data.thumbnailSource,
)
}
if (showError) {
ErrorView(
errorMessage = stringResource(id = CommonStrings.error_unknown),
@ -544,6 +553,7 @@ private fun MediaViewerTopBar(
@Composable
private fun MediaViewerBottomBar(
caption: String?,
formattedCaption: CharSequence?,
showDivider: Boolean,
onHeightChange: (Int) -> Unit,
modifier: Modifier = Modifier,
@ -556,7 +566,7 @@ private fun MediaViewerBottomBar(
onHeightChange(it.height)
},
) {
if (caption != null) {
if (caption != null || formattedCaption != null) {
if (showDivider) {
HorizontalDivider()
}
@ -567,15 +577,28 @@ private fun MediaViewerBottomBar(
.fillMaxWidth()
.heightIn(max = if (hasCompactHeightWindowSize()) maxCaptionHeightLandscape else maxCaptionHeightPortrait),
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(scrollState)
.navigationBarsPadding(),
text = caption,
style = ElementTheme.typography.fontBodyLgRegular,
)
val textToRender = when {
formattedCaption != null -> formattedCaption
caption != null -> caption.safeLinkify().toSpannable()
else -> null
}
if (textToRender != null) {
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
EditorStyledText(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(scrollState)
.navigationBarsPadding(),
text = textToRender,
style = ElementRichTextEditorStyle.textStyle(),
releaseOnDetach = false,
)
}
}
if (showBottomShadow) {
Box(
modifier = Modifier
@ -603,7 +626,6 @@ private val maxCaptionHeightLandscape = 128.dp
@Composable
private fun ThumbnailView(
thumbnailSource: MediaSource?,
isVisible: Boolean,
mediaInfo: MediaInfo,
modifier: Modifier = Modifier,
) {
@ -611,21 +633,19 @@ private fun ThumbnailView(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (isVisible) {
val mediaRequestData = MediaRequestData(
source = thumbnailSource,
kind = MediaRequestData.Kind.File(mediaInfo.filename, mediaInfo.mimeType)
)
val alpha = if (LocalInspectionMode.current) 0.1f else 1f
AsyncImage(
modifier = Modifier
.fillMaxSize()
.alpha(alpha),
model = mediaRequestData,
contentScale = ContentScale.Fit,
contentDescription = null,
)
}
val mediaRequestData = MediaRequestData(
source = thumbnailSource,
kind = MediaRequestData.Kind.File(mediaInfo.filename, mediaInfo.mimeType)
)
val alpha = if (LocalInspectionMode.current) 0.1f else 1f
AsyncImage(
modifier = Modifier
.fillMaxSize()
.alpha(alpha),
model = mediaRequestData,
contentScale = ContentScale.Fit,
contentDescription = null,
)
}
}

View file

@ -16,6 +16,7 @@
<string name="screen_media_details_filename">"Filnavn"</string>
<string name="screen_media_details_no_more_files_to_show">"Ikke flere filer at vise"</string>
<string name="screen_media_details_no_more_media_to_show">"Ikke flere medier at vise"</string>
<string name="screen_media_details_title">"Filoplysninger"</string>
<string name="screen_media_details_uploaded_by">"Uploadet af"</string>
<string name="screen_media_details_uploaded_on">"Uploadet på"</string>
</resources>

View file

@ -16,6 +16,7 @@
<string name="screen_media_details_filename">"Nom du fichier"</string>
<string name="screen_media_details_no_more_files_to_show">"Il ny a plus de fichiers à montrer"</string>
<string name="screen_media_details_no_more_media_to_show">"Il ny a plus de médias à montrer"</string>
<string name="screen_media_details_title">"Informations sur le fichier"</string>
<string name="screen_media_details_uploaded_by">"Envoyé par"</string>
<string name="screen_media_details_uploaded_on">"Envoyé le"</string>
</resources>

View file

@ -16,6 +16,7 @@
<string name="screen_media_details_filename">"Naziv datoteke"</string>
<string name="screen_media_details_no_more_files_to_show">"Nema više datoteka za prikaz"</string>
<string name="screen_media_details_no_more_media_to_show">"Nema više medijskih sadržaja za prikaz"</string>
<string name="screen_media_details_title">"Informacije o datoteci"</string>
<string name="screen_media_details_uploaded_by">"Prenio/la"</string>
<string name="screen_media_details_uploaded_on">"Preneseno na"</string>
</resources>

View file

@ -16,6 +16,7 @@
<string name="screen_media_details_filename">"Fájlnév"</string>
<string name="screen_media_details_no_more_files_to_show">"Nincs több megjeleníthető fájl"</string>
<string name="screen_media_details_no_more_media_to_show">"Nincs több megjeleníthető média"</string>
<string name="screen_media_details_title">"Fájlinformáció"</string>
<string name="screen_media_details_uploaded_by">"Feltöltötte:"</string>
<string name="screen_media_details_uploaded_on">"Feltöltve:"</string>
</resources>

View file

@ -16,6 +16,7 @@
<string name="screen_media_details_filename">"Nome del file"</string>
<string name="screen_media_details_no_more_files_to_show">"Nessun altro file da mostrare"</string>
<string name="screen_media_details_no_more_media_to_show">"Non ci sono più contenuti multimediali da mostrare"</string>
<string name="screen_media_details_title">"Informazioni sul file"</string>
<string name="screen_media_details_uploaded_by">"Caricato da"</string>
<string name="screen_media_details_uploaded_on">"Caricato il"</string>
</resources>

View file

@ -16,6 +16,7 @@
<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_title">"ファイル情報"</string>
<string name="screen_media_details_uploaded_by">"アップロード元"</string>
<string name="screen_media_details_uploaded_on">"アップロード先"</string>
</resources>

View file

@ -16,6 +16,7 @@
<string name="screen_media_details_filename">"Nazwa pliku"</string>
<string name="screen_media_details_no_more_files_to_show">"Brak plików do pokazania"</string>
<string name="screen_media_details_no_more_media_to_show">"Brak mediów do pokazania"</string>
<string name="screen_media_details_title">"Informacje pliku"</string>
<string name="screen_media_details_uploaded_by">"Przesłane przez"</string>
<string name="screen_media_details_uploaded_on">"Przesłane w dniu"</string>
</resources>

View file

@ -16,6 +16,7 @@
<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_title">"Інформація про файл"</string>
<string name="screen_media_details_uploaded_by">"Вивантажено користувачем"</string>
<string name="screen_media_details_uploaded_on">"Вивантажено"</string>
</resources>

View file

@ -235,6 +235,7 @@ class TimelineMediaGalleryDataSourceTest {
filename = "body.jpg",
fileSize = 888L,
caption = "body.jpg caption",
formattedCaption = "formatted",
mimeType = MimeTypes.Jpeg,
formattedFileSize = "888 Bytes",
fileExtension = "jpg",

View file

@ -593,20 +593,20 @@ class MediaViewerPresenterTest {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
)
} else {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
fileItems = persistentListOf(),
)
}
)
)
val updatedState = awaitItem()
// User navigate to the last item (forward loading indicator)
// User navigate to the first item (forward loading indicator)
updatedState.eventSink(
MediaViewerEvent.OnNavigateTo(2)
MediaViewerEvent.OnNavigateTo(0)
)
// data source claims that there is no more items to load forward
mediaGalleryDataSource.emitGroupedMediaItems(
@ -614,21 +614,19 @@ class MediaViewerPresenterTest {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aBackwardLoadingIndicator, anImage),
fileItems = persistentListOf(anImage, aBackwardLoadingIndicator),
)
} else {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage),
imageAndVideoItems = persistentListOf(anImage, aBackwardLoadingIndicator),
fileItems = persistentListOf(),
)
}
)
)
var stateWithSnackbar = awaitItem()
while (stateWithSnackbar.snackbarMessage == null) {
stateWithSnackbar = awaitItem()
}
assertThat(stateWithSnackbar.snackbarMessage.messageResId).isEqualTo(expectedSnackbarResId)
skipItems(1)
val stateWithSnackbar = awaitItem()
assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId)
}
}
@ -667,42 +665,41 @@ class MediaViewerPresenterTest {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
)
} else {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
fileItems = persistentListOf(),
)
}
)
)
val updatedState = awaitItem()
// User navigate to the first item (backward loading indicator)
// User navigate to the last item (backward loading indicator)
updatedState.eventSink(
MediaViewerEvent.OnNavigateTo(0)
MediaViewerEvent.OnNavigateTo(2)
)
skipItems(1)
// data source claims that there is no more items to load backward
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(anImage, aForwardLoadingIndicator),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage),
)
} else {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage, aForwardLoadingIndicator),
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage),
fileItems = persistentListOf(),
)
}
)
)
var stateWithSnackbar = awaitItem()
while (stateWithSnackbar.snackbarMessage == null) {
stateWithSnackbar = awaitItem()
}
assertThat(stateWithSnackbar.snackbarMessage.messageResId).isEqualTo(expectedSnackbarResId)
skipItems(1)
val stateWithSnackbar = awaitItem()
assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId)
}
}
@ -720,7 +717,7 @@ class MediaViewerPresenterTest {
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
fileItems = persistentListOf(),
)
)

View file

@ -41,6 +41,7 @@ class FakeLocalMediaFactory(
filename = safeName,
fileSize = null,
caption = null,
formattedCaption = null,
mimeType = mimeType ?: fallbackMimeType,
formattedFileSize = formattedFileSize ?: fallbackFileSize,
fileExtension = fileExtensionExtractor.extractFromName(safeName),

View file

@ -23,6 +23,9 @@ interface AppPreferencesStore {
suspend fun setTheme(theme: String)
fun getThemeFlow(): Flow<String?>
suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int)
fun getLiveLocationMinimumDistanceInMetersUpdateFlow(): Flow<Int>
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
suspend fun setHideInviteAvatars(hide: Boolean?)
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
@ -26,4 +27,6 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.sessionStorage.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.preferences.test)
}

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