This commit is contained in:
bxdxnn
2026-08-24 10:30:25 +00:00
318 changed files with 5976 additions and 2161 deletions
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.accountselect.test"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.accountselect.api)
implementation(projects.tests.testutils)
}
@@ -0,0 +1,23 @@
/*
* 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.accountselect.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakeAccountSelectEntryPoint(
private val createNodeResult: (BuildContext, AccountSelectEntryPoint.Callback) -> Node = { _, _ -> lambdaError() },
) : AccountSelectEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: AccountSelectEntryPoint.Callback,
): Node = createNodeResult(buildContext, callback)
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.media
import android.app.PictureInPictureParams
import android.content.res.Configuration
import android.os.Build
import android.util.Rational
import androidx.annotation.RequiresApi
/**
* Set the aspect ratio of the Picture-in-Picture mode based on the current orientation of the other user's device.
*/
@RequiresApi(Build.VERSION_CODES.O)
fun PictureInPictureParams.Builder.setAspectRatioFromOrientation(orientation: Int): PictureInPictureParams.Builder {
val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
// If landscape orientation, invert the aspect ratio
Rational(5, 3)
} else {
// In any other case, assume portrait orientation
Rational(3, 5)
}
return setAspectRatio(aspectRatio)
}
+3
View File
@@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
@@ -22,4 +23,6 @@ dependencies {
implementation(libs.androidx.corektx)
implementation(projects.libraries.di)
testCommonDependencies(libs)
}
@@ -48,9 +48,8 @@ class DefaultAudioFocus(
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
// For recording, ignore transient focus losses (e.g., notification sounds).
// The AudioRecord API keeps capturing regardless.
if (requester != AudioFocusRequester.RecordVoiceMessage) {
Timber.d("AudioFocus: transient loss ($it)")
if (requester.pausesOnTransientFocusLoss()) {
onFocusLost()
}
}
@@ -96,8 +95,8 @@ class DefaultAudioFocus(
private fun AudioFocusRequester.toAudioUsage(): Int {
return when (this) {
AudioFocusRequester.ElementCall,
AudioFocusRequester.VoiceMessage,
AudioFocusRequester.RecordVoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION
AudioFocusRequester.VoiceMessage,
AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA
}
}
@@ -105,12 +104,22 @@ private fun AudioFocusRequester.toAudioUsage(): Int {
private fun AudioFocusRequester.toAudioStream(): Int {
return when (this) {
AudioFocusRequester.ElementCall,
AudioFocusRequester.VoiceMessage,
AudioFocusRequester.RecordVoiceMessage -> AudioManager.STREAM_VOICE_CALL
AudioFocusRequester.VoiceMessage,
AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC
}
}
private fun AudioFocusRequester.pausesOnTransientFocusLoss(): Boolean {
return when (this) {
// The AudioRecord API keeps capturing regardless.
AudioFocusRequester.RecordVoiceMessage,
AudioFocusRequester.VoiceMessage -> false
AudioFocusRequester.ElementCall,
AudioFocusRequester.MediaViewer -> true
}
}
private fun AudioFocusRequester.willPausedWhenDucked(): Boolean {
return when (this) {
// (note that for Element Call, there is no action when the focus is lost)
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
* Copyright 2026 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.audio.impl
import android.media.AudioAttributes
import android.media.AudioManager
import androidx.core.content.getSystemService
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.audio.api.AudioFocusRequester
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
class DefaultAudioFocusTest : RobolectricTest() {
@Test
fun `voice message playback requests focus for media, not for a call`() {
assertThat(usageOf(AudioFocusRequester.VoiceMessage)).isEqualTo(AudioAttributes.USAGE_MEDIA)
}
@Test
fun `media viewer playback requests focus for media`() {
assertThat(usageOf(AudioFocusRequester.MediaViewer)).isEqualTo(AudioAttributes.USAGE_MEDIA)
}
@Test
fun `voice message recording requests focus for voice communication`() {
assertThat(usageOf(AudioFocusRequester.RecordVoiceMessage)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION)
}
@Test
fun `element call requests focus for voice communication`() {
assertThat(usageOf(AudioFocusRequester.ElementCall)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION)
}
@Test
fun `a transient focus loss does not pause a voice message`() {
assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse()
assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)).isFalse()
}
@Test
fun `a permanent focus loss pauses a voice message`() {
assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS)).isTrue()
}
@Test
fun `a transient focus loss does not stop a voice message recording`() {
assertThat(losesFocusOn(AudioFocusRequester.RecordVoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse()
}
@Test
fun `a transient focus loss still pauses the media viewer`() {
assertThat(losesFocusOn(AudioFocusRequester.MediaViewer, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isTrue()
}
private fun losesFocusOn(requester: AudioFocusRequester, focusChange: Int): Boolean {
val context = RuntimeEnvironment.getApplication()
val audioManager = requireNotNull(context.getSystemService<AudioManager>())
var focusLost = false
DefaultAudioFocus(context).requestAudioFocus(requester) { focusLost = true }
val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest)
request.listener.onAudioFocusChange(focusChange)
return focusLost
}
private fun usageOf(requester: AudioFocusRequester): Int {
val context = RuntimeEnvironment.getApplication()
val audioManager = requireNotNull(context.getSystemService<AudioManager>())
DefaultAudioFocus(context).requestAudioFocus(requester) {}
val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest)
return requireNotNull(request.audioFocusRequest).audioAttributes.usage
}
}
@@ -8,8 +8,11 @@
package io.element.android.compound.theme
import androidx.compose.foundation.isSystemInDarkTheme
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -27,12 +30,28 @@ private fun Theme.coerceBlackTheme(allowBlackTheme: Boolean): Theme {
@Composable
fun Theme.isDark(): Boolean {
return when (this) {
Theme.System -> isSystemInDarkTheme()
Theme.System -> isSystemThemeDark()
Theme.Dark, Theme.Black -> true
Theme.Light -> false
}
}
/**
* Whether the system-wide theme is dark.
*
* This deliberately does not use [androidx.compose.foundation.isSystemInDarkTheme], which reads the activity configuration: `AppCompatDelegate` rewrites
* that configuration whenever a non-system theme is selected, so it keeps reporting the previously forced value. The application configuration is left
* untouched by AppCompat and is what AppCompat itself reads to expand `MODE_NIGHT_FOLLOW_SYSTEM`.
*/
@Composable
private fun isSystemThemeDark(): Boolean {
val applicationContext = LocalContext.current.applicationContext
val activityConfiguration = LocalConfiguration.current
return remember(activityConfiguration) {
applicationContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
}
}
fun Flow<String?>.mapToTheme(allowBlackTheme: Boolean): Flow<Theme> = map {
when (it) {
null -> Theme.System
@@ -11,19 +11,22 @@ package io.element.android.compound.theme
import android.content.res.Configuration
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.robolectric.RuntimeEnvironment
class ThemeTest {
class ThemeTest : RobolectricTest() {
@Test
fun `isDark for System dark returns true`() {
`isDark for System`(
uiMode = Configuration.UI_MODE_NIGHT_YES,
systemUiMode = Configuration.UI_MODE_NIGHT_YES,
expected = true,
)
}
@@ -31,21 +34,38 @@ class ThemeTest {
@Test
fun `isDark for System light return false`() {
`isDark for System`(
uiMode = Configuration.UI_MODE_NIGHT_NO,
systemUiMode = Configuration.UI_MODE_NIGHT_NO,
expected = false,
)
}
fun `isDark for System`(
uiMode: Int,
@Test
fun `isDark for System ignores the night mode AppCompat forced on the activity`() {
`isDark for System`(
systemUiMode = Configuration.UI_MODE_NIGHT_YES,
activityUiMode = Configuration.UI_MODE_NIGHT_NO,
expected = true,
)
`isDark for System`(
systemUiMode = Configuration.UI_MODE_NIGHT_NO,
activityUiMode = Configuration.UI_MODE_NIGHT_YES,
expected = false,
)
}
private fun `isDark for System`(
systemUiMode: Int,
activityUiMode: Int = systemUiMode,
expected: Boolean,
) = runTest {
val application = RuntimeEnvironment.getApplication()
application.resources.configuration.uiMode = systemUiMode
moleculeFlow(RecompositionMode.Immediate) {
var result: Boolean? = null
CompositionLocalProvider(
// Let set the system to dark
LocalContext provides application,
LocalConfiguration provides Configuration().apply {
this.uiMode = uiMode
this.uiMode = activityUiMode
},
) {
result = Theme.System.isDark()
@@ -8,6 +8,7 @@
package io.element.android.libraries.core.extensions
import java.text.Normalizer
import java.util.Locale
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
@@ -114,3 +115,14 @@ fun String.toSafeLength(
this
}
}
/**
* Remove accents and replace special characters by ASCII from the string.
* For instance, "é" will be replaced by "e", "ç" by "c", etc.
*/
fun String.normalized(): String {
// Normalize the string.
return Normalizer.normalize(this, Normalizer.Form.NFD)
// then keep only ASCII characters and replace non-ASCII characters with an empty string.
.replace("[^\\p{ASCII}]".toRegex(), "")
}
@@ -63,4 +63,17 @@ object MimeTypes {
val subType = components.last()
return subType.isNotBlank() && subType != "*"
}
/**
* A mime type with a wildcard subtype is a filter, not a media type, so replace it with a
* concrete default for its top level type.
*/
fun String?.ensureDefaultSubtype(): String = when {
this == null -> OctetStream
hasSubtype(this) -> this
this == Images -> Jpeg
this == Videos -> Mp4
this == Audio -> Mp3
else -> OctetStream
}
}
@@ -20,12 +20,13 @@ fun String.isValidUrl(): Boolean {
}
/**
* Ensure string starts with "http". If it is not the case, "https://" is added, only if the String is not empty
* Ensure string starts with "http" (case-insensitively, so "HTTP://" / "HTTPS://" are respected). If it is
* not the case, "https://" is added, only if the String is not empty.
*/
fun String.ensureProtocol(): String {
return when {
isEmpty() -> this
!startsWith("http") -> "https://$this"
!startsWith("http", ignoreCase = true) -> "https://$this"
else -> this
}
}
@@ -74,4 +74,10 @@ class BasicExtensionsTest {
val result = textWithDirectionOverrides.filterDirectionOverrides()
assertEquals("123456789", result)
}
@Test
fun `normalized removes accents and replace special characters from text`() {
assertEquals("Stefan", "Ștefan".normalized())
assertEquals("ca enleve etre", "ça enlève être".normalized())
}
}
@@ -0,0 +1,35 @@
/*
* 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.core.mimetype
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes.ensureDefaultSubtype
import org.junit.Test
class MimeTypesTest {
@Test
fun `ensureDefaultSubtype keeps a mime type that already has a subtype`() {
assertThat(MimeTypes.Png.ensureDefaultSubtype()).isEqualTo(MimeTypes.Png)
assertThat(MimeTypes.Mp4.ensureDefaultSubtype()).isEqualTo(MimeTypes.Mp4)
assertThat(MimeTypes.Pdf.ensureDefaultSubtype()).isEqualTo(MimeTypes.Pdf)
}
@Test
fun `ensureDefaultSubtype replaces a wildcard subtype with a concrete one`() {
assertThat(MimeTypes.Images.ensureDefaultSubtype()).isEqualTo(MimeTypes.Jpeg)
assertThat(MimeTypes.Videos.ensureDefaultSubtype()).isEqualTo(MimeTypes.Mp4)
assertThat(MimeTypes.Audio.ensureDefaultSubtype()).isEqualTo(MimeTypes.Mp3)
}
@Test
fun `ensureDefaultSubtype falls back to octet stream`() {
assertThat(MimeTypes.Any.ensureDefaultSubtype()).isEqualTo(MimeTypes.OctetStream)
assertThat("text/*".ensureDefaultSubtype()).isEqualTo(MimeTypes.OctetStream)
assertThat(null.ensureDefaultSubtype()).isEqualTo(MimeTypes.OctetStream)
}
}
@@ -0,0 +1,35 @@
/*
* 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.core.uri
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class UrlUtilsTest {
@Test
fun `ensureProtocol keeps an empty string unchanged`() {
assertThat("".ensureProtocol()).isEqualTo("")
}
@Test
fun `ensureProtocol prepends https when there is no scheme`() {
assertThat("matrix.org".ensureProtocol()).isEqualTo("https://matrix.org")
}
@Test
fun `ensureProtocol keeps an existing http or https scheme`() {
assertThat("https://matrix.org".ensureProtocol()).isEqualTo("https://matrix.org")
assertThat("http://matrix.org".ensureProtocol()).isEqualTo("http://matrix.org")
}
@Test
fun `ensureProtocol keeps an uppercase scheme rather than double-prepending`() {
assertThat("HTTPS://matrix.org".ensureProtocol()).isEqualTo("HTTPS://matrix.org")
assertThat("Http://matrix.org".ensureProtocol()).isEqualTo("Http://matrix.org")
}
}
@@ -8,9 +8,11 @@
package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.runtime.Immutable
import io.element.android.libraries.core.data.tryOrNull
import java.text.BreakIterator
@Immutable
data class AvatarData(
val id: String,
val name: String?,
@@ -8,9 +8,11 @@
package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Immutable
enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(32.dp),
CurrentRoomTopBar(32.dp),
@@ -18,6 +20,7 @@ enum class AvatarSize(val dp: Dp) {
IncomingCall(140.dp),
RoomDetailsHeader(96.dp),
RoomListItem(52.dp),
SearchRoomListItem(32.dp),
ThreadsListItem(52.dp),
SpaceListItem(52.dp),
@@ -8,9 +8,19 @@
package io.element.android.libraries.matrix.api
/**
* An interface to fetch content from a URL using a simple GET request.
* An interface to fetch content from a URL using a simple GET request launched from a Matrix client.
*/
fun interface UrlContentFetcher {
interface ClientUrlContentFetcher {
/**
* The domain of the public server for this homeserver.
*/
val server: String?
/**
* The URL of the actual Matrix homeserver.
*/
val homeserverUrl: String
/**
* Execute generic GET requests through the SDKs internal HTTP client.
*/
@@ -60,16 +60,13 @@ import java.util.Optional
* One instance exists per session, created on login or session restore and torn down by [logout], [clearCache] or account deactivation.
* Suspending members here report failures through a [Result] instead of throwing, and run their work off the main thread.
*/
interface MatrixClient : UrlContentFetcher {
interface MatrixClient : ClientUrlContentFetcher {
/** Identifier of the logged in user; a [SessionId] and a [UserId] are the same value. */
val sessionId: SessionId
/** Identifier of this device, as assigned by the homeserver when the session was created. */
val deviceId: DeviceId
/** Base URL of the homeserver this client is connected to. */
val homeserverUrl: String
/** Directories holding this session's databases, caches and downloaded media. */
val sessionPaths: SessionPaths
@@ -136,6 +133,9 @@ interface MatrixClient : UrlContentFetcher {
/** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */
val contentScanner: ContentScanner?
/** Whether the session is in the process of shutting down, either because it is being logged out or its cache is being cleared. */
val isShuttingDown: Boolean
/**
* Returns the room with the given id if the user has joined it, along with its live [Timeline].
* A new instance is created on each call and the caller owns it, so it must be closed once no longer needed.
@@ -245,6 +245,9 @@ interface MatrixClient : UrlContentFetcher {
/** Whether the homeserver advertises support for user status (MSC4426). */
suspend fun isUserStatusSupported(): Result<Boolean>
/** Whether the homeserver advertises support for the Profiles sliding sync extension (MSC4262). */
suspend fun isProfilesSlidingSyncExtensionSupported(): Result<Boolean>
/**
* Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call.
*
@@ -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.matrix.api
/**
* An abstraction over the Matrix SDK's `ClientBuilder` to allow sharing it with other modules without exposing the SDK directly.
*/
interface MatrixClientBuilder
@@ -15,4 +15,4 @@ import java.lang.AutoCloseable
* Note: this client implements an [AutoCloseable] interface, so using [AutoCloseable.close] or [AutoCloseable.use] will clean up any resources associated
* with it, such as temporary files.
*/
interface TemporaryMatrixClient : UrlContentFetcher, AutoCloseable
interface TemporaryMatrixClient : ClientUrlContentFetcher, AutoCloseable
@@ -12,10 +12,10 @@ package io.element.android.libraries.matrix.api
*/
interface TemporaryMatrixClientFactory {
/**
* Builds an unauthenticated client pointing at [homeServerUrl], backed by a fresh set of temporary session directories.
* Builds an unauthenticated client pointing at [serverName], backed by a fresh set of temporary session directories.
* The caller owns the returned client and must close it to delete those directories.
*
* @param homeServerUrl the URL of the homeserver the client will talk to.
* @param serverName the domain of the public-facing server for the actual homeserver
*/
suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient>
suspend fun create(serverName: String): Result<TemporaryMatrixClient>
}
@@ -1,31 +0,0 @@
/*
* 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.scanner
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
/**
* Provides the URL of the content scanner service for a given homeserver, if any is set up.
*/
fun interface ContentScannerUrlProvider {
/**
* Returns the URL of the content scanner service for the given [sessionId], or `null` if no content scanner is set up.
*/
suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?>
/**
* Creates a provider, which needs a fetcher because the scanner URL is itself discovered over HTTP.
*/
fun interface Factory {
/**
* @param urlContentFetcher used to read the configuration advertised by the homeserver; a client that is not authenticated yet also works.
*/
fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider
}
}
@@ -128,4 +128,11 @@ sealed interface GalleryItemType {
data class OtherMessageType(
val msgType: String,
val body: String,
) : MessageType
) : MessageType {
/**
* A `m.key.verification.request` carries a body meant for clients that cannot handle in-chat
* verification, so it must not be rendered as a message.
*/
val isKeyVerificationRequest: Boolean
get() = msgType == "m.key.verification.request"
}
@@ -135,6 +135,7 @@ import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
import java.util.Optional
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -145,7 +146,7 @@ import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
@Suppress("LargeClass")
class RustMatrixClient(
override val sessionPaths: SessionPaths,
private val innerClient: Client,
val innerClient: Client,
private val sessionStore: SessionStore,
private val sessionDelegate: RustClientSessionDelegate,
private val innerSyncService: ClientSyncService,
@@ -162,6 +163,7 @@ class RustMatrixClient(
) : MatrixClient {
override val sessionId: UserId = UserId(innerClient.userId())
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
override val server: String? = innerClient.server()
override val homeserverUrl: String = innerClient.homeserver()
override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId")
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
@@ -304,6 +306,10 @@ class RustMatrixClient(
.buffer(Channel.UNLIMITED)
.stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf())
private val _isShuttingDown = AtomicBoolean(false)
override val isShuttingDown: Boolean
get() = _isShuttingDown.get()
init {
// Make sure the session delegate has a reference to the client to be able to logout on auth error
sessionDelegate.bindClient(this)
@@ -320,7 +326,9 @@ class RustMatrixClient(
}
private suspend fun setupUserProfile() {
val supported = isUserStatusSupported().getOrDefault(false)
// Subscribing to own profile updates only requires the Profiles sliding sync extension,
// not the full user status capability (which also needs the status profile field to be settable).
val supported = isProfilesSlidingSyncExtensionSupported().getOrDefault(false)
if (supported) {
// No need to seed the data here, it's already stored by the sdk.
ownProfileTaskHandle = innerClient.subscribeToOwnProfile(ownProfileListener)
@@ -530,6 +538,12 @@ class RustMatrixClient(
}
}
override suspend fun isProfilesSlidingSyncExtensionSupported(): Result<Boolean> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.isProfilesSlidingSyncExtensionSupported()
}
}
override fun enableAutomaticCallStatus(enabled: Boolean) {
innerClient.enableAutomaticCallStatus(enabled)
}
@@ -666,11 +680,13 @@ class RustMatrixClient(
}
override suspend fun clearCache() {
_isShuttingDown.set(true)
innerClient.clearCaches(innerSyncService)
destroy()
}
override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) {
_isShuttingDown.set(true)
sessionCoroutineScope.cancel()
// Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy()
@@ -708,6 +724,8 @@ class RustMatrixClient(
}
override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result<Unit> = withContext(sessionDispatcher) {
_isShuttingDown.set(true)
Timber.w("Deactivating account")
// Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy()
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl
import io.element.android.libraries.matrix.api.MatrixClientBuilder
import org.matrix.rustcomponents.sdk.ClientBuilder
/**
* A wrapper around the Matrix SDK's [ClientBuilder] to allow sharing it with other modules without exposing the SDK directly.
*/
class RustMatrixClientBuilder(
val inner: ClientBuilder,
) : MatrixClientBuilder
@@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.impl
import dev.zacsweers.metro.Inject
import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.ByteUnit
@@ -19,7 +20,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
import io.element.android.libraries.matrix.impl.analytics.UtdTracker
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
@@ -37,7 +37,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.ContentScanner
import org.matrix.rustcomponents.sdk.CrossProcessLockConfig
import org.matrix.rustcomponents.sdk.RequestConfig
import org.matrix.rustcomponents.sdk.Session
@@ -70,7 +69,7 @@ class RustMatrixClientFactory(
private val clientBuilderProvider: ClientBuilderProvider,
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
private val workManagerScheduler: WorkManagerScheduler,
private val contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory,
private val clientBuilderEnterpriseHook: ClientBuilderEnterpriseHook,
) {
private val sessionDelegate = RustClientSessionDelegate(
sessionStore = sessionStore,
@@ -101,6 +100,7 @@ class RustMatrixClientFactory(
)
.homeserverUrl(sessionData.homeserverUrl)
.username(sessionData.userId)
.let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner }
.use { it.build() }
client.setMediaRetentionPolicy(
@@ -135,21 +135,6 @@ class RustMatrixClientFactory(
client.setUtdDelegate(UtdTracker(analyticsService))
// If a content scanner URL is available for the homeserver, create a RustContentScanner and set it on the client.
// This allows the SDK to use the content scanner for automatic media scanning.
// If no content scanner URL is available, the contentScanner will be null.
val contentScannerUrlProvider = contentScannerUrlProviderFactory.create(RustTemporaryMatrixClient(client, null))
val contentScanner = contentScannerUrlProvider.getContentScannerUrl(SessionId(client.userId()))
.getOrNull()
?.let { contentScannerUrl ->
val contentScanner = ContentScanner(contentScannerUrl)
client.setContentScanner(contentScanner)
RustContentScanner(
client = client,
rustScanner = contentScanner,
)
}
val syncService = client.syncService()
.withSharePos(true)
.withOfflineMode()
@@ -170,7 +155,7 @@ class RustMatrixClientFactory(
featureFlagService = featureFlagService,
analyticsService = analyticsService,
workManagerScheduler = workManagerScheduler,
contentScanner = contentScanner,
contentScanner = client.contentScanner()?.let { RustContentScanner(client, it) },
isMessageSearchAvailable = isMessageSearchAvailable,
).also {
Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
@@ -22,6 +22,9 @@ class RustTemporaryMatrixClient(
client.getUrl(url)
}.mapFailure { it.mapClientException() }
override val server: String? = client.server()
override val homeserverUrl: String = client.homeserver()
override fun close() {
client.close()
paths?.let {
@@ -19,7 +19,7 @@ class RustTemporaryMatrixClientFactory(
private val sessionPathsFactory: SessionPathsFactory,
private val rustMatrixClientFactory: RustMatrixClientFactory,
) : TemporaryMatrixClientFactory {
override suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient> {
override suspend fun create(serverName: String): Result<TemporaryMatrixClient> {
return runCatchingExceptions {
val sessionPaths = sessionPathsFactory.create()
val client = rustMatrixClientFactory.getBaseClientBuilder(
@@ -28,7 +28,7 @@ class RustTemporaryMatrixClientFactory(
slidingSyncType = ClientBuilderSlidingSync.Native,
isMessageSearchAvailable = false,
)
.homeserverUrl(homeServerUrl)
.serverName(serverName)
.build()
RustTemporaryMatrixClient(client, sessionPaths)
}
@@ -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.ClientEnterpriseHook
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -73,6 +74,7 @@ class RustMatrixAuthenticationService(
private val oAuthConfigurationProvider: OAuthConfigurationProvider,
private val enterpriseService: EnterpriseService,
private val featureFlagService: FeatureFlagService,
private val clientEnterpriseHook: ClientEnterpriseHook,
) : MatrixAuthenticationService {
// Any existing Element Classic session that we want to try to import secrets from during login.
private var elementClassicSession: ElementClassicSession? = null
@@ -166,6 +168,10 @@ class RustMatrixAuthenticationService(
sessionPaths = currentSessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable())
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
@@ -242,6 +248,9 @@ class RustMatrixAuthenticationService(
client.restoreSession(sessionData.toSession())
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable())
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
// We wait for the verification state to be known
matrixClient.waitForKnownVerificationState()
@@ -278,7 +287,6 @@ class RustMatrixAuthenticationService(
.let {
enterpriseService.tweakMasUrl(
url = it,
homeserver = client.server() ?: client.homeserver(),
urlContentFetcher = getUrlResolver,
)
}
@@ -333,6 +341,10 @@ class RustMatrixAuthenticationService(
sessionPaths = currentSessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable())
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
matrixClient.waitForKnownVerificationState()
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
@@ -398,6 +410,10 @@ class RustMatrixAuthenticationService(
sessionPaths = emptySessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable())
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
@@ -13,9 +13,9 @@ import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
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.scanner.ContentScanner
import io.element.android.libraries.matrix.api.search.MessageSearchService
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -111,7 +112,12 @@ object SessionMatrixModule {
}
@Provides
fun providesGetUrlResolverClient(matrixClient: MatrixClient): UrlContentFetcher {
fun providesGetUrlResolverClient(matrixClient: MatrixClient): ClientUrlContentFetcher {
return matrixClient
}
@Provides
fun providesMessageSearchService(matrixClient: MatrixClient): MessageSearchService {
return matrixClient.messageSearchService
}
}
@@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.impl.media
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.ensureDefaultSubtype
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.MediaSource
@@ -71,15 +71,8 @@ class RustMediaLoader(
val mediaFile = innerClient.getMediaFile(
mediaSource = mediaSource,
filename = filename,
mimeType = when {
mimeType == null -> MimeTypes.OctetStream
MimeTypes.hasSubtype(mimeType) -> mimeType
// Fallback to a default mime type based on the main type, so that the SDK can create a file with the correct extension.
mimeType == MimeTypes.Images -> MimeTypes.Jpeg
mimeType == MimeTypes.Videos -> MimeTypes.Mp4
mimeType == MimeTypes.Audio -> MimeTypes.Mp3
else -> MimeTypes.OctetStream
},
// Fallback to a default mime type based on the main type, so that the SDK can create a file with the correct extension.
mimeType = mimeType.ensureDefaultSubtype(),
useCache = useCache,
tempDir = cacheDirectory.path,
)
@@ -11,7 +11,6 @@ package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
@@ -52,7 +51,6 @@ fun TestScope.createRustMatrixClientFactory(
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } },
) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope,
@@ -67,5 +65,5 @@ fun TestScope.createRustMatrixClientFactory(
clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
workManagerScheduler = workManagerScheduler,
contentScannerUrlProviderFactory = contentScannerUrlProviderFactory,
clientBuilderEnterpriseHook = { builder, _ -> builder },
)
@@ -9,7 +9,6 @@ package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
@@ -64,7 +63,6 @@ class RustTemporaryMatrixClientFactoryTest {
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } },
) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope,
@@ -79,6 +77,6 @@ class RustTemporaryMatrixClientFactoryTest {
clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
workManagerScheduler = workManagerScheduler,
contentScannerUrlProviderFactory = contentScannerUrlProviderFactory,
clientBuilderEnterpriseHook = { builder, _ -> builder },
)
}
@@ -16,7 +16,6 @@ import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.NoHandle
import java.io.File
import kotlin.io.path.createTempDirectory
import org.matrix.rustcomponents.sdk.ClientException as RustClientException
@@ -84,7 +83,7 @@ class RustTemporaryMatrixClientTest {
}
private fun createRustTemporaryMatrixClient(
client: Client = Client(NoHandle),
client: Client = FakeFfiClient(),
paths: SessionPaths,
): RustTemporaryMatrixClient {
return RustTemporaryMatrixClient(client, paths)
@@ -98,6 +98,7 @@ class RustMatrixAuthenticationServiceTest {
),
enterpriseService = enterpriseService,
featureFlagService = FakeFeatureFlagService(),
clientEnterpriseHook = {},
)
}
}
@@ -10,13 +10,16 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
import io.element.android.libraries.matrix.impl.scanner.FakeFfiContentScanner
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_SERVER_NAME
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.ContentScanner
import org.matrix.rustcomponents.sdk.CreateRoomParameters
import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
@@ -45,6 +48,7 @@ class FakeFfiClient(
private val userId: String = A_USER_ID.value,
private val deviceId: String = A_DEVICE_ID.value,
private val homeserver: String = A_HOMESERVER_URL,
private val server: String? = A_SERVER_NAME,
private val notificationClient: NotificationClient = FakeFfiNotificationClient(),
private val notificationSettings: NotificationSettings = FakeFfiNotificationSettings(),
private val encryption: Encryption = FakeFfiEncryption(),
@@ -59,13 +63,16 @@ class FakeFfiClient(
private val accountDataResult: (String) -> String? = { lambdaError() },
private val setAccountDataResult: (String, String) -> Unit = { _, _ -> lambdaError() },
private val isUserStatusSupportedResult: () -> Boolean = { false },
private val isProfilesSlidingSyncExtensionSupportedResult: () -> Boolean = { false },
private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {},
private val getUrlResult: (String) -> ByteArray = { lambdaError() },
private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() },
private val closeResult: () -> Unit = {},
) : Client(NoHandle) {
override fun userId(): String = userId
override fun deviceId(): String = deviceId
override fun homeserver(): String = homeserver
override fun server(): String? = server
override suspend fun notificationClient(processSetup: NotificationProcessSetup) = notificationClient
override suspend fun getNotificationSettings(): NotificationSettings = notificationSettings
override fun encryption(): Encryption = encryption
@@ -100,6 +107,8 @@ class FakeFfiClient(
override suspend fun isUserStatusSupported(): Boolean = isUserStatusSupportedResult()
override suspend fun isProfilesSlidingSyncExtensionSupported(): Boolean = isProfilesSlidingSyncExtensionSupportedResult()
override fun subscribeToOwnProfile(listener: ProfileListener): TaskHandle {
subscribeToOwnProfileResult(listener)
return FakeFfiTaskHandle()
@@ -139,5 +148,9 @@ class FakeFfiClient(
getUrlResult(url)
}
override suspend fun contentScanner(): ContentScanner = simulateLongTask {
contentScannerResult()
}
override fun close() = closeResult()
}
@@ -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.test
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.tests.testutils.lambda.lambdaError
class FakeClientUrlContentFetcher(
override val server: String = A_SERVER_NAME,
override val homeserverUrl: String = A_HOMESERVER_URL,
private val getUrlResult: (String) -> Result<ByteArray> = { lambdaError() },
) : ClientUrlContentFetcher {
override suspend fun getUrl(url: String): Result<ByteArray> {
return getUrlResult(url)
}
}
@@ -79,6 +79,7 @@ class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID,
override val sessionPaths: SessionPaths = SessionPaths(fileDirectory = File("files"), cacheDirectory = File("cache")),
override val deviceId: DeviceId = A_DEVICE_ID,
override val server: String = A_SERVER_NAME,
override val homeserverUrl: String = A_HOMESERVER_URL,
override val sessionCoroutineScope: CoroutineScope = TestScope(),
private val userDisplayName: String? = A_USER_NAME,
@@ -99,7 +100,7 @@ class FakeMatrixClient(
override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
private val resolveRoomAliasResult: suspend (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
Result.success(
Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
)
@@ -133,6 +134,7 @@ class FakeMatrixClient(
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { },
override val contentScanner: ContentScanner? = null,
private val isShuttingDownResult: () -> Boolean = { false },
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@@ -147,6 +149,7 @@ class FakeMatrixClient(
var setUserStatusResult: Result<Unit> = Result.success(Unit)
var clearUserStatusResult: Result<Unit> = Result.success(Unit)
var isUserStatusSupportedResult: Result<Boolean> = Result.success(true)
var isProfilesSlidingSyncExtensionSupportedResult: Result<Boolean> = Result.success(true)
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
override val userProfile: StateFlow<MatrixUser> = _userProfile
@@ -293,6 +296,8 @@ class FakeMatrixClient(
override suspend fun isUserStatusSupported(): Result<Boolean> = isUserStatusSupportedResult
override suspend fun isProfilesSlidingSyncExtensionSupported(): Result<Boolean> = isProfilesSlidingSyncExtensionSupportedResult
override fun enableAutomaticCallStatus(enabled: Boolean) = enableAutomaticCallStatusLambda(enabled)
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = joinRoomLambda(roomId)
@@ -305,6 +310,9 @@ class FakeMatrixClient(
return knockRoomLambda(roomIdOrAlias, message, serverNames)
}
override val isShuttingDown: Boolean
get() = isShuttingDownResult()
// Mocks
fun givenCreateRoomResult(result: Result<RoomId>) {
@@ -11,10 +11,11 @@ import io.element.android.libraries.matrix.api.TemporaryMatrixClient
import io.element.android.tests.testutils.lambda.lambdaError
class FakeTemporaryMatrixClient(
override val server: String = A_SERVER_NAME,
override val homeserverUrl: String = A_HOMESERVER_URL,
private val getUrlResult: (String) -> Result<ByteArray> = { lambdaError() },
private val closeLambda: () -> Unit = {},
) : TemporaryMatrixClient {
override suspend fun getUrl(url: String): Result<ByteArray> = getUrlResult(url)
override fun close() = closeLambda()
}
@@ -13,5 +13,5 @@ import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
class FakeTemporaryMatrixClientFactory(
private val createResult: (String) -> Result<TemporaryMatrixClient> = { Result.success(FakeTemporaryMatrixClient()) },
) : TemporaryMatrixClientFactory {
override suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient> = createResult(homeServerUrl)
override suspend fun create(serverName: String): Result<TemporaryMatrixClient> = createResult(serverName)
}
@@ -72,6 +72,7 @@ const val A_SPACE_NAME = "A space name"
const val A_REDACTION_REASON = "A redaction reason"
const val A_SERVER_NAME = "matrix.org"
const val A_HOMESERVER_URL = "matrix.org"
const val A_HOMESERVER_URL_2 = "matrix-client.org"
@@ -70,10 +70,15 @@ class FakeMatrixAuthenticationService(
return importCreatedSessionLambda(externalSession)
}
/** The login hint passed to the most recent [getOAuthUrl] call. */
var getOAuthUrlLoginHint: String? = null
private set
override suspend fun getOAuthUrl(
prompt: OAuthPrompt,
loginHint: String?,
): Result<OAuthDetails> = simulateLongTask {
getOAuthUrlLoginHint = loginHint
oAuthError?.let { Result.failure(it) } ?: Result.success(AN_OAUTH_DATA)
}
@@ -26,6 +26,7 @@ import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.ensureDefaultSubtype
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
@@ -82,16 +83,17 @@ class AndroidMediaPreProcessor(
mediaOptimizationConfig: MediaOptimizationConfig,
): Result<MediaUploadInfo> = withContext(coroutineDispatchers.computation) {
runCatchingExceptions {
val resolvedMimeType = mimeType.ensureDefaultSubtype()
val result = when {
mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType)
mimeType.isMimeTypeImage() -> {
val imageMimeType = resolveImageMimeType(uri, mimeType)
resolvedMimeType == MimeTypes.Svg -> processSvgImage(uri, resolvedMimeType)
resolvedMimeType.isMimeTypeImage() -> {
val imageMimeType = resolveImageMimeType(uri, mimeType).ensureDefaultSubtype()
val shouldBeCompressed = mediaOptimizationConfig.compressImages && imageMimeType !in notCompressibleImageTypes
processImage(uri, imageMimeType, shouldBeCompressed)
}
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset)
mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType)
else -> processFile(uri, mimeType)
resolvedMimeType.isMimeTypeVideo() -> processVideo(uri, resolvedMimeType, mediaOptimizationConfig.videoCompressionPreset)
resolvedMimeType.isMimeTypeAudio() -> processAudio(uri, resolvedMimeType)
else -> processFile(uri, resolvedMimeType)
}
if (deleteOriginal) {
tryOrNull {
@@ -65,6 +65,20 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
assertThat(height).isEqualTo(2268)
}
@Test
fun `test processing an image shared with a wildcard mime type`() = runTest {
val mediaUploadInfo = process(
asset = assetImageJpeg,
mimeType = MimeTypes.Images,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Jpeg)
}
private suspend fun TestScope.process(
asset: Asset,
mediaOptimizationConfig: MediaOptimizationConfig,
@@ -124,7 +138,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
}
@Test
fun `a file that is not an image keeps the wildcard mime type it was shared with`() = runTest {
fun `a file that is not an image falls back to the default subtype of the wildcard it was shared with`() = runTest {
val mediaUploadInfo = process(
asset = assetText,
mimeType = MimeTypes.Images,
@@ -135,7 +149,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
sdkIntVersion = Build.VERSION_CODES.Q,
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Images)
assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Jpeg)
}
@Test
@@ -266,4 +266,5 @@ class ExoPlayerForPreview(
override fun setVideoCodecParameters(codecParameters: CodecParameters) {}
override fun addVideoCodecParametersChangeListener(listener: CodecParametersChangeListener, keys: List<String>) {}
override fun removeVideoCodecParametersChangeListener(listener: CodecParametersChangeListener) {}
override fun setEnforceAdPlaybackOnTimelineRefresh(enforceAdPlaybackOnTimelineRefresh: Boolean) = throw NotImplementedError()
}
@@ -92,7 +92,7 @@ class MediaViewerNode(
is MediaViewerEntryPoint.Params.RoomMedia -> {
val eventId = inputs.eventId
if (eventId == null) {
// Should not happen
// Local echo, the viewer will not be focused on the item
timelineMediaGalleryDataSource
} else {
// Can we use a specific timeline?
@@ -12,6 +12,7 @@ import android.os.Build
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.extensions.normalized
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.SdkMetadata
@@ -26,11 +27,11 @@ class DefaultUserAgentProvider(
override fun provide(): String = userAgent
/**
* Create an user agent with the application version.
* Create a user agent with the application version.
* Ex: Element X/1.5.0 (Xiaomi Mi 9T; Android 11; RKQ1.200826.002; Sdk c344b155c)
*/
private fun buildUserAgent(): String {
val appName = buildMeta.applicationName
val appName = buildMeta.applicationName.normalized()
val appVersion = buildMeta.versionName
val deviceManufacturer = Build.MANUFACTURER
val deviceModel = Build.MODEL
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oauth.test.customtab
package io.element.android.libraries.oauth.test
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
@@ -84,6 +84,20 @@ interface AppPreferencesStore {
/** The configured log level; defaults to the one the build was compiled with. */
fun getTracingLogLevelFlow(): Flow<LogLevel>
/**
* The list of account providers (homeserver URLs) the user has previously authenticated
* against, most recent first. Used to default the account provider and to power autocomplete
* suggestions during sign-in. Stored locally only; never synced across devices.
*/
fun getHomeserverHistoryFlow(): Flow<List<String>>
/**
* Add [url] to the front of the account provider history (see [getHomeserverHistoryFlow]).
* The value is normalised (trimmed + lowercased); existing case-insensitive duplicates are
* moved to the front rather than duplicated, and the list is capped in size. No-op for blanks.
*/
suspend fun addHomeserverToHistory(url: String)
/**
* @param targets the extra log packs to enable on top of the default targets.
*/
@@ -8,6 +8,7 @@
package io.element.android.libraries.preferences.impl.store
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
@@ -36,6 +37,7 @@ private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPr
private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate")
private val logLevelKey = stringPreferencesKey("logLevel")
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
private val homeserverHistoryKey = stringPreferencesKey("homeserverHistory")
private val messageSoundUriKey = stringPreferencesKey("notificationMessageSoundUri")
private val messageSoundChannelVersionKey = intPreferencesKey("notificationMessageSoundChannelVersion")
private val messageSoundDisplayNameKey = stringPreferencesKey("notificationMessageSoundDisplayName")
@@ -43,6 +45,10 @@ private val callRingtoneUriKey = stringPreferencesKey("notificationCallRingtoneU
private val callRingtoneChannelVersionKey = intPreferencesKey("notificationCallRingtoneChannelVersion")
private val callRingtoneDisplayNameKey = stringPreferencesKey("notificationCallRingtoneDisplayName")
// URLs never contain a newline, so it is a safe delimiter to persist an ordered list in a single String.
private const val HOMESERVER_HISTORY_DELIMITER = "\n"
private const val MAX_HOMESERVER_HISTORY_SIZE = 20
@ContributesBinding(AppScope::class)
class DefaultAppPreferencesStore(
private val buildMeta: BuildMeta,
@@ -168,6 +174,20 @@ class DefaultAppPreferencesStore(
}
}
override fun getHomeserverHistoryFlow(): Flow<List<String>> {
return store.data.map { prefs -> prefs.readHomeserverHistory() }
}
override suspend fun addHomeserverToHistory(url: String) {
val normalized = url.trim().lowercase()
if (normalized.isEmpty()) return
store.edit { prefs ->
val updated = (listOf(normalized) + prefs.readHomeserverHistory().filter { it != normalized })
.take(MAX_HOMESERVER_HISTORY_SIZE)
prefs[homeserverHistoryKey] = updated.joinToString(HOMESERVER_HISTORY_DELIMITER)
}
}
override fun getMessageSoundFlow(): Flow<NotificationSound> {
return store.data.map { prefs -> NotificationSound.fromStored(prefs[messageSoundUriKey]) }
}
@@ -242,6 +262,13 @@ class DefaultAppPreferencesStore(
}
}
private fun Preferences.readHomeserverHistory(): List<String> {
return this[homeserverHistoryKey]
?.split(HOMESERVER_HISTORY_DELIMITER)
?.filter { it.isNotEmpty() }
?: emptyList()
}
private fun BuildMeta.defaultLogLevel(): LogLevel {
return when (buildType) {
BuildType.DEBUG -> LogLevel.TRACE
@@ -54,4 +54,74 @@ class DefaultAppPreferencesStoreTest {
assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(25)
}
@Test
fun `homeserver history is empty by default`() = runTest {
val store = createStore()
assertThat(store.getHomeserverHistoryFlow().first()).isEmpty()
}
@Test
fun `adding homeservers keeps the most recent first`() = runTest {
val store = createStore()
store.addHomeserverToHistory("https://matrix.org")
store.addHomeserverToHistory("https://example.com")
assertThat(store.getHomeserverHistoryFlow().first())
.containsExactly("https://example.com", "https://matrix.org")
.inOrder()
}
@Test
fun `adding an existing homeserver moves it to the front without duplicating`() = runTest {
val store = createStore()
store.addHomeserverToHistory("https://matrix.org")
store.addHomeserverToHistory("https://example.com")
store.addHomeserverToHistory("https://matrix.org")
assertThat(store.getHomeserverHistoryFlow().first())
.containsExactly("https://matrix.org", "https://example.com")
.inOrder()
}
@Test
fun `homeservers are normalised and deduplicated case-insensitively`() = runTest {
val store = createStore()
store.addHomeserverToHistory(" HTTPS://Matrix.ORG ")
store.addHomeserverToHistory("https://matrix.org")
assertThat(store.getHomeserverHistoryFlow().first())
.containsExactly("https://matrix.org")
}
@Test
fun `blank homeservers are ignored`() = runTest {
val store = createStore()
store.addHomeserverToHistory(" ")
assertThat(store.getHomeserverHistoryFlow().first()).isEmpty()
}
@Test
fun `homeserver history is capped to the most recent entries`() = runTest {
val store = createStore()
repeat(25) { index ->
store.addHomeserverToHistory("https://server$index.org")
}
val history = store.getHomeserverHistoryFlow().first()
assertThat(history).hasSize(20)
assertThat(history.first()).isEqualTo("https://server24.org")
assertThat(history.last()).isEqualTo("https://server5.org")
}
private fun createStore() = DefaultAppPreferencesStore(
buildMeta = buildMeta,
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
)
}
@@ -16,6 +16,7 @@ import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.preferences.api.store.NotificationSoundChannelConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
class InMemoryAppPreferencesStore(
@@ -27,6 +28,7 @@ class InMemoryAppPreferencesStore(
liveLocationMinimumDistanceUpdate: Int = 10,
logLevel: LogLevel = LogLevel.INFO,
traceLogPacks: Set<TraceLogPack> = emptySet(),
homeserverHistory: List<String> = emptyList(),
messageSound: NotificationSound = NotificationSound.SystemDefault,
messageSoundChannelVersion: Int = 0,
messageSoundDisplayName: String? = null,
@@ -40,6 +42,7 @@ class InMemoryAppPreferencesStore(
private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate)
private val logLevel = MutableStateFlow(logLevel)
private val tracingLogPacks = MutableStateFlow(traceLogPacks)
private val homeserverHistory = MutableStateFlow(homeserverHistory)
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue)
private val messageSound = MutableStateFlow(messageSound)
@@ -117,6 +120,18 @@ class InMemoryAppPreferencesStore(
return tracingLogPacks
}
override fun getHomeserverHistoryFlow(): Flow<List<String>> {
return homeserverHistory
}
override suspend fun addHomeserverToHistory(url: String) {
val normalized = url.trim().lowercase()
if (normalized.isEmpty()) return
homeserverHistory.update { current ->
(listOf(normalized) + current.filter { it != normalized }).take(10)
}
}
override fun getMessageSoundFlow(): Flow<NotificationSound> {
return messageSound
}
+29
View File
@@ -1,3 +1,5 @@
import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.setupDependencyInjection
import extension.testCommonDependencies
@@ -17,6 +19,33 @@ plugins {
android {
namespace = "io.element.android.libraries.push.impl"
buildFeatures {
buildConfig = true
}
buildTypes {
val defaultPusherAppId = "im.vector.app.android"
getByName("release") {
buildConfigFieldStr(
name = "PUSHER_APP_ID",
value = BuildTimeConfig.PUSHER_APP_ID_RELEASE ?: defaultPusherAppId,
)
}
getByName("debug") {
buildConfigFieldStr(
name = "PUSHER_APP_ID",
value = BuildTimeConfig.PUSHER_APP_ID_DEBUG ?: defaultPusherAppId,
)
}
register("nightly") {
matchingFallbacks += listOf("release")
buildConfigFieldStr(
name = "PUSHER_APP_ID",
value = BuildTimeConfig.PUSHER_APP_ID_NIGHTLY ?: defaultPusherAppId,
)
}
}
testOptions {
unitTests {
isIncludeAndroidResources = true
@@ -10,7 +10,6 @@ package io.element.android.libraries.push.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.appconfig.PushConfig
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
@@ -74,7 +73,7 @@ class DefaultPusherSubscriber(
): SetHttpPusherData =
SetHttpPusherData(
pushKey = pushKey,
appId = PushConfig.PUSHER_APP_ID,
appId = BuildConfig.PUSHER_APP_ID,
// TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode())
profileTag = DEFAULT_PUSHER_FILE_TAG + "_",
// TODO localeProvider.current().language
@@ -104,7 +103,7 @@ class DefaultPusherSubscriber(
.unsetHttpPusher(
unsetHttpPusherData = UnsetHttpPusherData(
pushKey = pushKey,
appId = PushConfig.PUSHER_APP_ID
appId = BuildConfig.PUSHER_APP_ID
)
)
.onSuccess {
@@ -10,9 +10,9 @@ package io.element.android.libraries.push.impl.test
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.appconfig.PushConfig
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.push.impl.BuildConfig
import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest
import io.element.android.libraries.pushproviders.api.Config
@@ -28,7 +28,7 @@ class DefaultTestPush(
pushGatewayNotifyRequest.execute(
PushGatewayNotifyRequest.Params(
url = config.url,
appId = PushConfig.PUSHER_APP_ID,
appId = BuildConfig.PUSHER_APP_ID,
pushKey = config.pushKey,
eventId = TEST_EVENT_ID,
roomId = TEST_ROOM_ID,
@@ -9,7 +9,6 @@
package io.element.android.libraries.push.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.PushConfig
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData
import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData
@@ -96,7 +95,7 @@ class DefaultPusherSubscriberTest {
value(
SetHttpPusherData(
pushKey = "aPushKey",
appId = PushConfig.PUSHER_APP_ID,
appId = BuildConfig.PUSHER_APP_ID,
url = "aGateway",
appDisplayName = "MyApp",
deviceDisplayName = "MyDevice",
@@ -163,7 +162,7 @@ class DefaultPusherSubscriberTest {
value(
UnsetHttpPusherData(
pushKey = "aPushKey",
appId = PushConfig.PUSHER_APP_ID,
appId = BuildConfig.PUSHER_APP_ID,
),
)
)
@@ -8,7 +8,7 @@
package io.element.android.libraries.push.impl.test
import io.element.android.appconfig.PushConfig
import io.element.android.libraries.push.impl.BuildConfig
import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest
import io.element.android.libraries.pushproviders.test.aSessionPushConfig
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -33,7 +33,7 @@ class DefaultTestPushTest {
value(
PushGatewayNotifyRequest.Params(
url = aConfig.url,
appId = PushConfig.PUSHER_APP_ID,
appId = BuildConfig.PUSHER_APP_ID,
pushKey = aConfig.pushKey,
eventId = DefaultTestPush.TEST_EVENT_ID,
roomId = DefaultTestPush.TEST_ROOM_ID,
+2 -2
View File
@@ -2,6 +2,6 @@
## Configuration
In order to make this module only know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module.
In order to make only this module know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module.
To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app/src/main`, build the project, and check the generated file `app/build/generated/res/google-services/<buildtype>/values/values.xml` to import the generated values into the `build.gradle.kts` files.
To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app`, build the project, and check the generated file `app/build/generated/res/google-services/<buildtype>/values/values.xml` to import the generated values into the `firebase.xml` files.
@@ -8,7 +8,6 @@
@file:Suppress("UnstableApiUsage")
import config.BuildTimeConfig
import extension.setupDependencyInjection
import extension.testCommonDependencies
import org.gradle.kotlin.dsl.withType
@@ -28,27 +27,10 @@ android {
buildTypes {
getByName("release") {
consumerProguardFiles("consumer-proguard-rules.pro")
resValue(
type = "string",
name = "google_app_id",
value = BuildTimeConfig.GOOGLE_APP_ID_RELEASE,
)
}
getByName("debug") {
resValue(
type = "string",
name = "google_app_id",
value = BuildTimeConfig.GOOGLE_APP_ID_DEBUG,
)
}
register("nightly") {
consumerProguardFiles("consumer-proguard-rules.pro")
matchingFallbacks += listOf("release")
resValue(
type = "string",
name = "google_app_id",
value = BuildTimeConfig.GOOGLE_APP_ID_NIGHTLY,
)
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="google_app_id" translatable="false" tools:ignore="UnusedResources">1:912726360885:android:def0a4e454042e9b00427c</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="google_app_id" translatable="false" tools:ignore="UnusedResources">1:912726360885:android:e17435e0beb0303000427c</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="google_app_id" translatable="false" tools:ignore="UnusedResources">1:912726360885:android:d097de99a4c23d2700427c</string>
</resources>
@@ -245,6 +245,20 @@ fun TextComposer(
canSendTextMessage,
) {
when {
composerMode.isEditing -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_edited_message,
endButtonClick = {
if (canSendTextMessage) {
onSendMessage()
}
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = canSendTextMessage,
isEditing = true,
)
},
)
!canSendTextMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle -> EndButtonParams(
@@ -296,18 +310,6 @@ fun TextComposer(
)
}
}
composerMode.isEditing -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_edited_message,
endButtonClick = {
onSendMessage()
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = true,
isEditing = true,
)
},
)
else -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_message,
endButtonClick = {
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
* Copyright 2026 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.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.libraries.textcomposer
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.wysiwyg.display.TextDisplay
import org.junit.Test
class TextComposerEndButtonTest : RobolectricTest() {
@Test
fun `editing a message with text shows the send edited message button`() = runAndroidComposeUiTest {
setTextComposer(text = "Some text", composerMode = aMessageComposerModeEdit())
onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed()
}
@Test
fun `emptying the composer while editing keeps the send edited message button`() = runAndroidComposeUiTest {
setTextComposer(text = "", composerMode = aMessageComposerModeEdit())
onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed()
onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertDoesNotExist()
}
@Test
fun `emptying the composer while editing a caption keeps the send edited message button`() = runAndroidComposeUiTest {
setTextComposer(text = "", composerMode = aMessageComposerModeEditCaption(content = "An existing caption"))
onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed()
onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertDoesNotExist()
}
@Test
fun `an empty composer outside of edition still offers to record a voice message`() = runAndroidComposeUiTest {
setTextComposer(text = "", composerMode = MessageComposerMode.Normal)
onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertIsDisplayed()
}
private fun AndroidComposeUiTest<ComponentActivity>.getString(resId: Int): String {
return activity!!.getString(resId)
}
private fun AndroidComposeUiTest<ComponentActivity>.setTextComposer(
text: String,
composerMode: MessageComposerMode,
) {
setContent {
TextComposer(
state = aTextEditorStateMarkdown(initialText = text, initialFocus = true),
voiceMessageState = VoiceMessageState.Idle,
composerMode = composerMode,
onRequestFocus = {},
onSendMessage = {},
onResetComposerMode = {},
onAddAttachment = {},
onDismissTextFormatting = {},
onVoiceRecorderEvent = {},
onVoicePlayerEvent = {},
onSendVoiceMessage = {},
onDeleteVoiceMessage = {},
onError = {},
onTyping = {},
onReceiveSuggestion = {},
onSelectRichContent = null,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
)
}
}
}
@@ -516,14 +516,6 @@ Motivo: %1$s."</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Agitar con fuerza para informar de un error"</string>
<string name="screen_bug_report_a11y_screenshot">"Captura de pantalla"</string>
<string name="screen_change_server_navigation_title_login">"Iniciar sesión"</string>
<string name="screen_change_server_navigation_title_register">"Crear cuenta"</string>
<string name="screen_change_server_textfield_footer_login">"Si tu cuenta te la proporciona tu empresa o una comunidad, cambia el proveedor de la cuenta (ejemplo: nombredeempresa.com)."</string>
<string name="screen_change_server_textfield_footer_register">"Crea una cuenta gratuita en matrix.org. Si deseas crear una cuenta con otro proveedor, modifícala según corresponda (por ejemplo: communityname.org)."</string>
<string name="screen_change_server_textfield_header">"Proveedor de cuentas"</string>
<string name="screen_change_server_textfield_placeholder">"ejemplo.com"</string>
<string name="screen_change_server_title_login">"Introduce tu proveedor de cuenta"</string>
<string name="screen_change_server_title_register">"Elegir proveedor de cuentas"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Opciones"</string>
<string name="screen_create_poll_remove_accessibility_label">"Eliminar %1$s"</string>
@@ -507,14 +507,6 @@ Kas sa oled kindel, et soovid jätkata?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Veast teatamiseks raputa nutiseadet ägedalt"</string>
<string name="screen_bug_report_a11y_screenshot">"Ekraanitõmmis"</string>
<string name="screen_change_server_navigation_title_login">"Logi sisse"</string>
<string name="screen_change_server_navigation_title_register">"Loo kasutajakonto"</string>
<string name="screen_change_server_textfield_footer_login">"Kui sinu kasutajakonto paikneb äriühingu või kogukonna serveris, siis muuda kontoteenuse pakkujat (näiteks: minufirmanimi.ee)."</string>
<string name="screen_change_server_textfield_footer_register">"Loo tasuta kasutajakonto matrix.org serverisse. Kui soovid luua kasutajakonto mõne teise teenusepakkuja juures, siis muuda seda vastavalt (näiteks: minukogukond.ee)."</string>
<string name="screen_change_server_textfield_header">"Teenusepakkuja"</string>
<string name="screen_change_server_textfield_placeholder">"domeeninäidis.ee"</string>
<string name="screen_change_server_title_login">"Sisesta oma kasutajakonto teenusepakkuja"</string>
<string name="screen_change_server_title_register">"Vali kasutajakonto teenusepakkuja"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Valikud"</string>
<string name="screen_create_poll_remove_accessibility_label">"Kustuta: %1$s"</string>
@@ -516,14 +516,6 @@ Haluatko varmasti jatkaa?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Raivostunut ravistaminen ilmoittaa virheestä"</string>
<string name="screen_bug_report_a11y_screenshot">"Näyttökuva"</string>
<string name="screen_change_server_navigation_title_login">"Kirjaudu sisään"</string>
<string name="screen_change_server_navigation_title_register">"Luo tili"</string>
<string name="screen_change_server_textfield_footer_login">"Jos tilisi on yrityksesi tai yhteisön tarjoama, vaihda palveluntarjoaja (esimerkki: companyname.com)."</string>
<string name="screen_change_server_textfield_footer_register">"Luo tili matrix.org -palvelimelle ilmaiseksi. Jos haluat luoda tilin eri palveluntarjoajan kautta, muuta sitä vastaavasti (esimerkiksi: communityname.org)."</string>
<string name="screen_change_server_textfield_header">"Palveluntarjoaja"</string>
<string name="screen_change_server_textfield_placeholder">"example.com"</string>
<string name="screen_change_server_title_login">"Anna palveluntarjoajasi"</string>
<string name="screen_change_server_title_register">"Valitse palveluntarjoaja"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Vaihtoehdot"</string>
<string name="screen_create_poll_remove_accessibility_label">"Poista %1$s"</string>
@@ -516,14 +516,6 @@ Raison : %1$s."</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake pour signaler un problème"</string>
<string name="screen_bug_report_a11y_screenshot">"Capture d’écran"</string>
<string name="screen_change_server_navigation_title_login">"Se connecter"</string>
<string name="screen_change_server_navigation_title_register">"Créer un compte"</string>
<string name="screen_change_server_textfield_footer_login">"Si votre fournisseur de compte est fourni par votre entreprise ou une communauté, veuillez le saisir ici (exemple : monentreprise.com)."</string>
<string name="screen_change_server_textfield_footer_register">"Créez un compte sur matrix.org gratuitement. Si vous souhaitez créer un compte auprès d’un autre fournisseur, veuillez modifier l’adresse ici (exemple : monassociation.org)."</string>
<string name="screen_change_server_textfield_header">"Fournisseur de compte"</string>
<string name="screen_change_server_textfield_placeholder">"exemple.com"</string>
<string name="screen_change_server_title_login">"Saisissez votre fournisseur de compte"</string>
<string name="screen_change_server_title_register">"Choisissez un fournisseur de compte"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Options"</string>
<string name="screen_create_poll_remove_accessibility_label">"Supprimer %1$s"</string>
@@ -528,14 +528,6 @@ Czy na pewno chcesz kontynuować?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Wstrząśnij gniewnie, aby zgłosić błąd"</string>
<string name="screen_bug_report_a11y_screenshot">"Zrzut ekranu"</string>
<string name="screen_change_server_navigation_title_login">"Zaloguj"</string>
<string name="screen_change_server_navigation_title_register">"Utwórz konto"</string>
<string name="screen_change_server_textfield_footer_login">"Jeśli uzyskałeś konto od firmy lub społeczności, wprowadź dostawcę konta (na przykład: nazwafirmy.com)"</string>
<string name="screen_change_server_textfield_footer_register">"Załóż darmowe konto na matrix.org. Jeśli chcesz założyć konto u innego dostawcy wprowadź własny adres (na przykład: nazwaspołeczności.org)."</string>
<string name="screen_change_server_textfield_header">"Dostawca konta"</string>
<string name="screen_change_server_textfield_placeholder">"przykład.com"</string>
<string name="screen_change_server_title_login">"Wprowadź dostawcę swojego konta"</string>
<string name="screen_change_server_title_register">"Wybierz dostawcę konta"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Opcje"</string>
<string name="screen_create_poll_remove_accessibility_label">"Usuń %1$s"</string>
@@ -528,14 +528,6 @@ Sunteți sigur că doriți să continuați?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake pentru a raporta erori"</string>
<string name="screen_bug_report_a11y_screenshot">"Captură de ecran"</string>
<string name="screen_change_server_navigation_title_login">"Autentificare"</string>
<string name="screen_change_server_navigation_title_register">"Creați un cont"</string>
<string name="screen_change_server_textfield_footer_login">"Dacă contul este furnizat de compania dumneavoastră sau de o comunitate, schimbați furnizorul contului (exemplu: numecompanie.com)."</string>
<string name="screen_change_server_textfield_footer_register">"Creați un cont gratuit pe matrix.org. Dacă doriți să creați un cont la un alt furnizor, schimbați-l în consecință (exemplu: numecomunitate.org)."</string>
<string name="screen_change_server_textfield_header">"Furnizor de cont"</string>
<string name="screen_change_server_textfield_placeholder">"exemplu.ro"</string>
<string name="screen_change_server_title_login">"Introduceți furnizorul contului dumneavoastră"</string>
<string name="screen_change_server_title_register">"Alegeți un furnizor de cont"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Opțiuni"</string>
<string name="screen_create_poll_remove_accessibility_label">"Ștergeți %1$s"</string>
@@ -528,14 +528,6 @@
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Повідомляти про помилку потрусивши телефон"</string>
<string name="screen_bug_report_a11y_screenshot">"Знімок екрана"</string>
<string name="screen_change_server_navigation_title_login">"Увійти"</string>
<string name="screen_change_server_navigation_title_register">"Створити обліковий запис"</string>
<string name="screen_change_server_textfield_footer_login">"Якщо ваш обліковий запис надано вашою компанією або спільнотою, змініть постачальника облікового запису (наприклад: companyname.com)."</string>
<string name="screen_change_server_textfield_footer_register">"Створіть обліковий запис на matrix.org безплатно. Якщо ви хочете створити обліковий запис у іншого постачальника, змініть його відповідно (наприклад: communityname.org)."</string>
<string name="screen_change_server_textfield_header">"Постачальник облікового запису"</string>
<string name="screen_change_server_textfield_placeholder">"example.com"</string>
<string name="screen_change_server_title_login">"Вкажіть постачальника вашого облікового запису"</string>
<string name="screen_change_server_title_register">"Оберіть постачальника облікового запису"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Варіанти"</string>
<string name="screen_create_poll_remove_accessibility_label">"Вилучити %1$s"</string>
@@ -504,14 +504,6 @@
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"摇一摇以报告 bug"</string>
<string name="screen_bug_report_a11y_screenshot">"屏幕截图"</string>
<string name="screen_change_server_navigation_title_login">"登录"</string>
<string name="screen_change_server_navigation_title_register">"创建账户"</string>
<string name="screen_change_server_textfield_footer_login">"如果你的账户由你的公司或社区提供,则应更改账户提供者(例如:companyname.com)。"</string>
<string name="screen_change_server_textfield_footer_register">"在 matrix.org 创建免费账户。如果你想使用其它服务提供者创建账户,请相应地更改域名(例如:communityname.org)。"</string>
<string name="screen_change_server_textfield_header">"账户提供者"</string>
<string name="screen_change_server_textfield_placeholder">"example.com"</string>
<string name="screen_change_server_title_login">"输入你的账户提供者"</string>
<string name="screen_change_server_title_register">"选择账户提供者"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s:%2$s"</string>
<string name="screen_create_poll_options_section_title">"选项"</string>
<string name="screen_create_poll_remove_accessibility_label">"移除 %1$s"</string>
@@ -516,14 +516,6 @@ Are you sure you want to continue?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_bug_report_a11y_screenshot">"Screenshot"</string>
<string name="screen_change_server_navigation_title_login">"Sign in"</string>
<string name="screen_change_server_navigation_title_register">"Create account"</string>
<string name="screen_change_server_textfield_footer_login">"If your account is provided by your company or a community, then change the account provider (example: companyname.com)."</string>
<string name="screen_change_server_textfield_footer_register">"Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)."</string>
<string name="screen_change_server_textfield_header">"Account provider"</string>
<string name="screen_change_server_textfield_placeholder">"example.com"</string>
<string name="screen_change_server_title_login">"Enter your account provider"</string>
<string name="screen_change_server_title_register">"Choose an account provider"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Options"</string>
<string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string>
@@ -19,6 +19,5 @@ dependencies {
implementation(projects.tests.testutils)
implementation(libs.coroutines.test)
implementation(libs.test.truth)
implementation(projects.libraries.core)
}
@@ -8,13 +8,13 @@
package io.element.android.libraries.voicerecorder.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.yield
import java.io.File
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -23,51 +23,47 @@ import kotlin.time.TestTimeSource
class FakeVoiceRecorder(
private val timeSource: TestTimeSource = TestTimeSource(),
private val recordingDuration: Duration = 0.seconds,
private val levels: List<Float> = listOf(0.1f, 0.2f)
private val levels: List<Float> = listOf(0.1f, 0.2f),
val waveform: List<Float> = A_WAVEFORM,
private val startRecordResult: () -> Unit = { lambdaError() },
private val stopRecordResult: (Boolean) -> Unit = { lambdaError() },
private val deleteRecordingResult: () -> Unit = { lambdaError() },
) : VoiceRecorder {
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
override val state: StateFlow<VoiceRecorderState> = _state
private var curRecording: File? = null
private val levelInterval = if (levels.isEmpty()) Duration.ZERO else recordingDuration / levels.size
private var securityException: SecurityException? = null
private var currentRecording: File? = null
private var lastRecordingId = 0
private var activeRecordingId = 0
private var startedCount = 0
private var stoppedCount = 0
private var deletedCount = 0
var waveform: List<Float> = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f)
override suspend fun startRecord() {
startedCount += 1
startRecordResult()
if (activeRecordingId != 0) return
val recordingId = ++lastRecordingId
activeRecordingId = recordingId
currentRecording = File("file.ogg")
val startedAt = timeSource.markNow()
securityException?.let { throw it }
if (curRecording != null) {
error("Previous recording was not cleared")
}
curRecording = File("file.ogg")
timeSource += recordingDuration
for (i in 1..levels.size) {
delay(levelInterval)
if (activeRecordingId != recordingId) return
timeSource += levelInterval
_state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), levels.take(i)))
yield()
}
}
override suspend fun stopRecord(
cancelled: Boolean
) {
stoppedCount++
override suspend fun stopRecord(cancelled: Boolean) {
stopRecordResult(cancelled)
activeRecordingId = 0
if (cancelled) {
deleteRecording()
}
_state.emit(
when (curRecording) {
when (val file = currentRecording) {
null -> VoiceRecorderState.Idle
else -> VoiceRecorderState.Finished(
file = curRecording!!,
file = file,
mimeType = MimeTypes.Ogg,
duration = recordingDuration,
waveform = waveform,
@@ -77,25 +73,11 @@ class FakeVoiceRecorder(
}
override suspend fun deleteRecording() {
deletedCount++
curRecording = null
_state.emit(
VoiceRecorderState.Idle
)
}
fun assertCalls(
started: Int = 0,
stopped: Int = 0,
deleted: Int = 0,
) {
assertThat(startedCount).isEqualTo(started)
assertThat(stoppedCount).isEqualTo(stopped)
assertThat(deletedCount).isEqualTo(deleted)
}
fun givenThrowsSecurityException(exception: SecurityException) {
this.securityException = exception
deleteRecordingResult()
activeRecordingId = 0
currentRecording = null
_state.emit(VoiceRecorderState.Idle)
}
}
private val A_WAVEFORM = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f)