Merge branch 'develop' into feature/valere/call/decline_timeline_rendering
This commit is contained in:
commit
a478d87fc3
995 changed files with 7864 additions and 3674 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
libraries/cachestore/api/build.gradle.kts
Normal file
13
libraries/cachestore/api/build.gradle.kts
Normal 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"
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
48
libraries/cachestore/impl/build.gradle.kts
Normal file
48
libraries/cachestore/impl/build.gradle.kts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
BIN
libraries/cachestore/impl/src/main/sqldelight/databases/1.db
Normal file
BIN
libraries/cachestore/impl/src/main/sqldelight/databases/1.db
Normal file
Binary file not shown.
|
|
@ -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;
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
17
libraries/cachestore/test/build.gradle.kts
Normal file
17
libraries/cachestore/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -13,3 +13,7 @@ plugins {
|
|||
android {
|
||||
namespace = "io.element.android.libraries.cryptography.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ setupDependencyInjection()
|
|||
|
||||
dependencies {
|
||||
implementation(projects.libraries.di)
|
||||
implementation(libs.coroutines.core)
|
||||
api(projects.libraries.cryptography.api)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ android {
|
|||
|
||||
dependencies {
|
||||
api(projects.libraries.cryptography.api)
|
||||
implementation(libs.coroutines.core)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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?>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -19,4 +19,6 @@ data class RoomPowerLevelsValues(
|
|||
val roomAvatar: Long,
|
||||
val roomTopic: Long,
|
||||
val spaceChild: Long,
|
||||
val beacon: Long,
|
||||
val beaconInfo: Long,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ object RoomPowerLevelsValuesMapper {
|
|||
roomAvatar = values.roomAvatar,
|
||||
roomTopic = values.roomTopic,
|
||||
spaceChild = values.spaceChild,
|
||||
beacon = values.beacon,
|
||||
beaconInfo = values.beaconInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -32,4 +32,6 @@ fun defaultFfiRoomPowerLevelValues() = RoomPowerLevelsValues(
|
|||
roomTopic = 50,
|
||||
spaceChild = 50,
|
||||
usersDefault = 0,
|
||||
beacon = 0,
|
||||
beaconInfo = 0,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) },
|
||||
|
|
|
|||
|
|
@ -223,4 +223,6 @@ fun defaultRoomPowerLevelValues() = RoomPowerLevelsValues(
|
|||
roomAvatar = 50,
|
||||
roomTopic = 50,
|
||||
spaceChild = 50,
|
||||
beacon = 0,
|
||||
beaconInfo = 0,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
|
|||
filename = filename,
|
||||
fileSize = null,
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
mimeType = mimeType,
|
||||
formattedFileSize = "",
|
||||
fileExtension = "",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ class AndroidLocalMediaFactory(
|
|||
filename = fileName,
|
||||
fileSize = fileSize,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
formattedFileSize = calculatedFormattedFileSize,
|
||||
fileExtension = fileExtension,
|
||||
senderId = senderId,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 n’y a plus de fichiers à montrer"</string>
|
||||
<string name="screen_media_details_no_more_media_to_show">"Il n’y 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class FakeLocalMediaFactory(
|
|||
filename = safeName,
|
||||
fileSize = null,
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
mimeType = mimeType ?: fallbackMimeType,
|
||||
formattedFileSize = formattedFileSize ?: fallbackFileSize,
|
||||
fileExtension = fileExtensionExtractor.extractFromName(safeName),
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue