mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Merge
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+23
@@ -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)
|
||||
}
|
||||
+29
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+14
-5
@@ -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)
|
||||
|
||||
+80
@@ -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()
|
||||
|
||||
+12
@@ -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(), "")
|
||||
}
|
||||
|
||||
+13
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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")
|
||||
}
|
||||
}
|
||||
+2
@@ -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?,
|
||||
|
||||
+3
@@ -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),
|
||||
|
||||
+12
-2
@@ -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.
|
||||
*/
|
||||
+7
-4
@@ -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.
|
||||
*
|
||||
|
||||
+13
@@ -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
|
||||
+1
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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>
|
||||
}
|
||||
|
||||
-31
@@ -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
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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"
|
||||
}
|
||||
|
||||
+20
-2
@@ -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()
|
||||
|
||||
+18
@@ -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
|
||||
+4
-19
@@ -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'")
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
|
||||
+17
-1
@@ -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)
|
||||
|
||||
|
||||
+8
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+3
-10
@@ -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,
|
||||
)
|
||||
|
||||
+1
-3
@@ -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 },
|
||||
)
|
||||
|
||||
+1
-3
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
+1
-2
@@ -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)
|
||||
|
||||
+1
@@ -98,6 +98,7 @@ class RustMatrixAuthenticationServiceTest {
|
||||
),
|
||||
enterpriseService = enterpriseService,
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
clientEnterpriseHook = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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()
|
||||
}
|
||||
|
||||
+21
@@ -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)
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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>) {
|
||||
|
||||
+2
-1
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+1
@@ -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"
|
||||
|
||||
|
||||
+5
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+8
-6
@@ -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 {
|
||||
|
||||
+16
-2
@@ -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
|
||||
|
||||
+1
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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?
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
+14
@@ -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.
|
||||
*/
|
||||
|
||||
+27
@@ -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
|
||||
|
||||
+70
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+15
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-3
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-3
@@ -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,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
+2
-2
@@ -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,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>
|
||||
+14
-12
@@ -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 = {
|
||||
|
||||
+85
@@ -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)
|
||||
}
|
||||
|
||||
+30
-48
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user