From 26e4e895a9cd1dbfa09a75dee70b321ffc642e16 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 1 Jul 2026 17:51:12 +0200 Subject: [PATCH 01/22] Fix accessibility: announce checked/unchecked state for invite user rows (#6407) Replace clickable(role = Role.Checkbox) with toggleable(value = checked, role = Role.Checkbox) so TalkBack can announce the current state and state changes. Set the inner Checkbox.onCheckedChange = null to make it a purely visual indicator, avoiding duplicate accessibility focus on the same control. Co-Authored-By: Claude Sonnet 4.6 --- .../libraries/matrix/ui/components/CheckableUserRow.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt index 917b03f309..dfcc154da4 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt @@ -8,12 +8,12 @@ package io.element.android.libraries.matrix.ui.components -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment @@ -39,8 +39,8 @@ fun CheckableUserRow( Row( modifier = modifier .fillMaxWidth() - .clickable(role = Role.Checkbox, enabled = enabled) { - onCheckedChange(!checked) + .toggleable(value = checked, role = Role.Checkbox, enabled = enabled) { + onCheckedChange(it) }, verticalAlignment = Alignment.CenterVertically, ) { @@ -65,7 +65,7 @@ fun CheckableUserRow( } } Checkbox( - onCheckedChange = onCheckedChange, + onCheckedChange = null, checked = checked, enabled = enabled, ) From 9c66cd331f61de6e51bf1393b05ec9cf675a3f4c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 4 May 2026 18:09:06 +0200 Subject: [PATCH 02/22] [Link new device] Handle split steps in the SDK --- .../impl/LinkNewDeviceFlowNode.kt | 15 +++++++++ .../ContinuationMessageSender.kt | 13 ++++++++ .../api/linknewdevice/LinkDesktopHandler.kt | 2 ++ .../api/linknewdevice/LinkMobileHandler.kt | 7 ++++- .../RustContinuationMessageSender.kt | 31 +++++++++++++++++++ .../linknewdevice/RustLinkDesktopHandler.kt | 4 +++ .../linknewdevice/RustLinkMobileHandler.kt | 8 ++++- .../FakeContinuationMessageSender.kt | 25 +++++++++++++++ .../RustLinkDesktopHandlerTest.kt | 18 ++++++----- .../RustLinkMobileHandlerTest.kt | 18 ++++++----- .../android/tests/testutils/ExpectedResult.kt | 28 +++++++++++++++++ 11 files changed, 152 insertions(+), 17 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ContinuationMessageSender.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeContinuationMessageSender.kt create mode 100644 tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ExpectedResult.kt diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt index d686b22860..b1492f53cc 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt @@ -54,8 +54,10 @@ import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep import io.element.android.libraries.matrix.api.logs.LoggerTags import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import timber.log.Timber @@ -102,6 +104,7 @@ class LinkNewDeviceFlowNode( @Suppress("AssignedValueIsNeverRead") linkDesktopHandlerJob = observeLinkNewDesktopHandler() }, + onResume = ::onResume, onDestroy = { linkMobileHandlerJob?.cancel() linkDesktopHandlerJob?.cancel() @@ -209,6 +212,18 @@ class LinkNewDeviceFlowNode( .launchIn(sessionCoroutineScope) } + private fun onResume() = sessionCoroutineScope.launch { + // Application is resumed, if the step is waiting for auth, send the confirmation + (linkNewMobileHandler.stepFlow.first() as? LinkMobileStep.WaitingForAuth)?.let { + Timber.tag(tag.value).d("Resuming while waiting for auth on mobile, sending confirmation") + it.continuationMessageSender.confirm() + } + (linkNewDesktopHandler.stepFlow.value as? LinkDesktopStep.WaitingForAuth)?.let { + Timber.tag(tag.value).d("Resuming while waiting for auth on desktop, sending confirmation") + it.continuationMessageSender.confirm() + } + } + private fun navigateToError(errorType: ErrorType) { // Map the error to an error screen val error = when (errorType) { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ContinuationMessageSender.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ContinuationMessageSender.kt new file mode 100644 index 0000000000..0bbbaf3187 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ContinuationMessageSender.kt @@ -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.linknewdevice + +interface ContinuationMessageSender { + suspend fun cancel(): Result + suspend fun confirm(): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt index d600c1a8bf..343624aa34 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt @@ -18,8 +18,10 @@ interface LinkDesktopHandler { sealed interface LinkDesktopStep { data object Uninitialized : LinkDesktopStep data object Starting : LinkDesktopStep + data class WaitingForAuth( val verificationUri: String, + val continuationMessageSender: ContinuationMessageSender, ) : LinkDesktopStep data class EstablishingSecureChannel( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt index eb2d49b5b0..f187b8275a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt @@ -22,7 +22,12 @@ sealed interface LinkMobileStep { data object Starting : LinkMobileStep data class QrReady(val data: String) : LinkMobileStep data object QrRotating : LinkMobileStep - data class WaitingForAuth(val verificationUri: String) : LinkMobileStep + + data class WaitingForAuth( + val verificationUri: String, + val continuationMessageSender: ContinuationMessageSender, + ) : LinkMobileStep + data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep data class Error(val errorType: ErrorType) : LinkMobileStep data object SyncingSecrets : LinkMobileStep diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt new file mode 100644 index 0000000000..98cf29418c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt @@ -0,0 +1,31 @@ +/* + * 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.linknewdevice + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.linknewdevice.ContinuationMessageSender +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.ContinuationMessageSender as FfiContinuationMessageSender + +class RustContinuationMessageSender( + private val inner: FfiContinuationMessageSender, + private val sessionDispatcher: CoroutineDispatcher, +) : ContinuationMessageSender { + override suspend fun cancel(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + inner.cancel() + } + } + + override suspend fun confirm(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + inner.confirm() + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt index 83e657eb07..8c9695ae4b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt @@ -75,6 +75,10 @@ class RustLinkDesktopHandler( GrantQrLoginProgress.SyncingSecrets -> LinkDesktopStep.SyncingSecrets is GrantQrLoginProgress.WaitingForAuth -> LinkDesktopStep.WaitingForAuth( verificationUri = verificationUri, + continuationMessageSender = RustContinuationMessageSender( + inner = continuationSender, + sessionDispatcher = sessionDispatcher, + ) ) is GrantQrLoginProgress.EstablishingSecureChannel -> LinkDesktopStep.EstablishingSecureChannel( checkCode = checkCode, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt index a44a8d5943..33f1ad736d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt @@ -84,7 +84,13 @@ class RustLinkMobileHandler( ) GrantGeneratedQrLoginProgress.Starting -> LinkMobileStep.Starting GrantGeneratedQrLoginProgress.SyncingSecrets -> LinkMobileStep.SyncingSecrets - is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(verificationUri) + is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth( + verificationUri = verificationUri, + continuationMessageSender = RustContinuationMessageSender( + inner = continuationSender, + sessionDispatcher = sessionDispatcher, + ) + ) } } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeContinuationMessageSender.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeContinuationMessageSender.kt new file mode 100644 index 0000000000..d1544ec8bb --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeContinuationMessageSender.kt @@ -0,0 +1,25 @@ +/* + * 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.linknewdevice + +import io.element.android.libraries.matrix.api.linknewdevice.ContinuationMessageSender +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeContinuationMessageSender( + private val cancelResult: () -> Result = { lambdaError() }, + private val confirmResult: () -> Result = { lambdaError() }, +) : ContinuationMessageSender { + override suspend fun cancel(): Result = simulateLongTask { + cancelResult() + } + + override suspend fun confirm(): Result = simulateLongTask { + confirmResult() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt index c0781972ac..8a0a046946 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt @@ -16,6 +16,8 @@ import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuationMessageSender import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler import io.element.android.libraries.matrix.test.QR_CODE_DATA +import io.element.android.tests.testutils.ExpectedResult +import io.element.android.tests.testutils.match import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -50,16 +52,18 @@ class RustLinkDesktopHandlerTest { runCurrent() // progress from the handler is mapped and emitted listOf( - GrantQrLoginProgress.Starting to LinkDesktopStep.Starting, - GrantQrLoginProgress.SyncingSecrets to LinkDesktopStep.SyncingSecrets, + GrantQrLoginProgress.Starting to ExpectedResult(LinkDesktopStep.Starting), + GrantQrLoginProgress.SyncingSecrets to ExpectedResult(LinkDesktopStep.SyncingSecrets), GrantQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender()) - to LinkDesktopStep.WaitingForAuth("aVerificationUri"), + to ExpectedResult( + resultClass = LinkDesktopStep.WaitingForAuth::class.java, + ), GrantQrLoginProgress.EstablishingSecureChannel(1.toUByte(), "1") - to LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1"), - GrantQrLoginProgress.Done to LinkDesktopStep.Done, - ).forEach { (progress, expectedStep) -> + to ExpectedResult(LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1")), + GrantQrLoginProgress.Done to ExpectedResult(LinkDesktopStep.Done), + ).forEach { (progress, expectedResult) -> handler.emitScanProgress(progress) - assertThat(awaitItem()).isEqualTo(expectedStep) + assertThat(awaitItem()).match(expectedResult) } // scan returns, no new event is emitted completable.complete(Unit) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt index 8c0306c8ad..f1f568e3ca 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt @@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuati import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE +import io.element.android.tests.testutils.ExpectedResult +import io.element.android.tests.testutils.match import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -51,18 +53,18 @@ class RustLinkMobileHandlerTest { runCurrent() // progress from the handler is mapped and emitted listOf( - GrantGeneratedQrLoginProgress.Starting to LinkMobileStep.Starting::class.java, - GrantGeneratedQrLoginProgress.SyncingSecrets to LinkMobileStep.SyncingSecrets::class.java, + GrantGeneratedQrLoginProgress.Starting to ExpectedResult(LinkMobileStep.Starting), + GrantGeneratedQrLoginProgress.SyncingSecrets to ExpectedResult(LinkMobileStep.SyncingSecrets), GrantGeneratedQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender()) - to LinkMobileStep.WaitingForAuth::class.java, + to ExpectedResult(LinkMobileStep.WaitingForAuth::class.java), GrantGeneratedQrLoginProgress.QrScanned(FakeFfiCheckCodeSender()) - to LinkMobileStep.QrScanned::class.java, + to ExpectedResult(LinkMobileStep.QrScanned::class.java), GrantGeneratedQrLoginProgress.QrReady(FakeFfiQrCodeData(toBytesResult = { QR_CODE_DATA_RECIPROCATE })) - to LinkMobileStep.QrReady::class.java, - GrantGeneratedQrLoginProgress.Done to LinkMobileStep.Done::class.java, - ).forEach { (progress, expectedStepClass) -> + to ExpectedResult(LinkMobileStep.QrReady::class.java), + GrantGeneratedQrLoginProgress.Done to ExpectedResult(LinkMobileStep.Done), + ).forEach { (progress, expectedResult) -> handler.emitGenerateProgress(progress) - assertThat(awaitItem()).isInstanceOf(expectedStepClass) + assertThat(awaitItem()).match(expectedResult) } // generate returns, no new event is emitted completable.complete(Unit) diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ExpectedResult.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ExpectedResult.kt new file mode 100644 index 0000000000..b5e383f324 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ExpectedResult.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.tests.testutils + +import com.google.common.truth.Subject + +/** + * A helper class to assert expected results in tests. It can be used to assert either the type of result or its value. + */ +data class ExpectedResult( + val resultClass: Class, + val result: T? = null, +) { + constructor(result: T) : this(result::class.java, result) +} + +fun Subject.match(expectedResult: ExpectedResult) { + if (expectedResult.result != null) { + isEqualTo(expectedResult.result) + } else { + isInstanceOf(expectedResult.resultClass) + } +} From e1daec6728a88c8b48ab5ace5f223e1f77e44448 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:06:16 +0000 Subject: [PATCH 03/22] Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.2 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f934d1918c..818d7767c4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ firebaseAppDistribution = "5.3.0" core = "1.18.0" datastore = "1.2.1" constraintlayout = "2.2.2" -constraintlayout_compose = "1.1.1" +constraintlayout_compose = "1.1.2" lifecycle = "2.11.0" activity = "1.13.0" media3 = "1.10.1" From df925c29e4005213355e671eef52e5b1556016dd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:06:22 +0200 Subject: [PATCH 04/22] Update dependency org.jsoup:jsoup to v1.23.1 (#7332) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Benoit Marty --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce602f0e93..f365c5cb3b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -199,7 +199,7 @@ serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-jso kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.5.1" showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" } showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } -jsoup = "org.jsoup:jsoup:1.22.2" +jsoup = "org.jsoup:jsoup:1.23.1" appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } molecule-runtime = "app.cash.molecule:molecule-runtime:2.2.0" timber = "com.jakewharton.timber:timber:5.0.1" From 8f8c3af8c557b5b9ff95938c56575a8c4dc1ed76 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Aug 2026 16:42:49 +0200 Subject: [PATCH 05/22] [LinkNewDevice] Log continuation / cancellation failure. --- .../impl/linknewdevice/RustContinuationMessageSender.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt index 98cf29418c..f4f17fb9a7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt @@ -11,6 +11,7 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.linknewdevice.ContinuationMessageSender import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext +import timber.log.Timber import org.matrix.rustcomponents.sdk.ContinuationMessageSender as FfiContinuationMessageSender class RustContinuationMessageSender( @@ -20,12 +21,16 @@ class RustContinuationMessageSender( override suspend fun cancel(): Result = withContext(sessionDispatcher) { runCatchingExceptions { inner.cancel() + }.onFailure { + Timber.e(it, "Error cancelling continuation message") } } override suspend fun confirm(): Result = withContext(sessionDispatcher) { runCatchingExceptions { inner.confirm() + }.onFailure { + Timber.e(it, "Error confirming continuation message") } } } From 668243f658dc13b5f76f959f04a43f3172bacc2f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Aug 2026 16:43:52 +0200 Subject: [PATCH 06/22] Use value instead of first(). --- .../features/linknewdevice/impl/LinkNewDeviceFlowNode.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt index b1492f53cc..afa570f0a9 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt @@ -54,7 +54,6 @@ import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep import io.element.android.libraries.matrix.api.logs.LoggerTags import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -214,7 +213,7 @@ class LinkNewDeviceFlowNode( private fun onResume() = sessionCoroutineScope.launch { // Application is resumed, if the step is waiting for auth, send the confirmation - (linkNewMobileHandler.stepFlow.first() as? LinkMobileStep.WaitingForAuth)?.let { + (linkNewMobileHandler.stepFlow.value as? LinkMobileStep.WaitingForAuth)?.let { Timber.tag(tag.value).d("Resuming while waiting for auth on mobile, sending confirmation") it.continuationMessageSender.confirm() } From 5140b7c3c883e2c78234e471b7fcfaa42b2a81ab Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:10:00 +0300 Subject: [PATCH 07/22] Fix the feature module template writing the impl build script into the api module Generating a feature with the Generate Module from Template plugin produced a module that could not build: features//api/build.gradle.kts was written from the impl template, giving the api module a dependency on itself and an impl namespace. FeatureModule.json declared BUILD_GRADLE_API and BUILD_GRADLE_IMPL, both defaulting to build.gradle.kts, and used them as fileTemplates keys. The plugin resolves keys to filenames before matching, so both collapsed to one key and the second overwrote the first. The same matching code falls back to a suffix match on the path, so DefaultXEntryPoint also matched the key XEntryPoint and was generated from the api template. Key both by directory, which the plugin supports for exactly this case, and drop the two now-unused placeholders. The file templates themselves were correct and are unchanged. The onboarding doc gains a note that an already imported template must be removed before re-importing, since the plugin keeps its own copy. --- docs/_developer_onboarding.md | 8 ++++++-- tools/templates/FeatureModule.json | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md index 74020ead98..dbac9107c4 100644 --- a/docs/_developer_onboarding.md +++ b/docs/_developer_onboarding.md @@ -303,6 +303,8 @@ Follow these steps to install and configure the plugin and templates: - Click on OK 4. Configure generate-module-from-template plugin : - Navigate to AS/Settings/Tools/Module Template Settings + - If a `FeatureModule` template is already listed, select it and remove it, since the plugin keeps its own copy of the + imported template and will not pick up changes made to `FeatureModule.json` on its own - Click on + / Import From File - Pick the `tools/templates/FeatureModule.json` @@ -321,8 +323,10 @@ Example for a new feature called RoomDetails: 5. The modules api/impl should be created under `features/roomdetails` directory. 6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle). 7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`. - To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`. - Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package. + To use it, just right click on the package where you want to generate classes, and click on `New` / `Template Presentation Classes`. + Fill the text field with the base name of the classes: the generated files are `Event.kt`, `State.kt`, + `StateProvider.kt`, `Presenter.kt`, `Node.kt` and `View.kt`. For instance + `PreferencesRoot` in the `features/preferences/impl/root` package. Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a diff --git a/tools/templates/FeatureModule.json b/tools/templates/FeatureModule.json index 4ad5e3a676..ff2d1b4ecc 100644 --- a/tools/templates/FeatureModule.json +++ b/tools/templates/FeatureModule.json @@ -1 +1 @@ -{"template":{"name":"","isDir":true,"placeholders":{"MODULE_NAME":"","FEATURE_NAME":"","BUILD_GRADLE_API":"build.gradle.kts","BUILD_GRADLE_IMPL":"build.gradle.kts"},"fileTemplates":{"${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point API","Default${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point Flow Impl","${BUILD_GRADLE_API}":"Template Module Feature Build Gradle API","${BUILD_GRADLE_IMPL}":"Template Module Feature Build Gradle Impl","${FEATURE_NAME}FlowNode":"Template Module Feature Node Flow Impl"},"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]}]},{"name":"${BUILD_GRADLE_API}","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]},{"name":"impl","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[{"name":"Default${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]},{"name":"${FEATURE_NAME}FlowNode","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"test","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"${BUILD_GRADLE_IMPL}","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]},"language":"java","templateName":"FeatureModule","lowercaseDir":true,"capitalizeFile":false,"packageNameToDir":false} \ No newline at end of file +{"template":{"name":"","isDir":true,"placeholders":{"MODULE_NAME":"","FEATURE_NAME":""},"fileTemplates":{"api/${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point API","impl/Default${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point Flow Impl","api/build.gradle.kts":"Template Module Feature Build Gradle API","impl/build.gradle.kts":"Template Module Feature Build Gradle Impl","impl/${FEATURE_NAME}FlowNode":"Template Module Feature Node Flow Impl"},"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]}]},{"name":"build.gradle.kts","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]},{"name":"impl","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[{"name":"Default${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]},{"name":"${FEATURE_NAME}FlowNode","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"test","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"build.gradle.kts","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]},"language":"java","templateName":"FeatureModule","lowercaseDir":true,"capitalizeFile":false,"packageNameToDir":false} \ No newline at end of file From 38398c24b2fbfc0c9d7617b9e6064d4321977bfa Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:19:45 +0300 Subject: [PATCH 08/22] Show a Message not found placeholder when a replied to message cannot be loaded When the replied to event could not be fetched, the reply preview rendered the raw SDK error string in critical styling, and tapping it emitted a focus request that could never succeed, so the user got a dead end dialog. Render the localised error_message_not_found string in the neutral italic style already used for redacted and undecryptable replies, and drop the click handler for that case. The existing key is reused rather than adding a new one; a "you do not have access" wording was rejected because this state covers any failure to load the event, not only a permission problem. --- .../components/TimelineItemEventRow.kt | 8 +-- .../impl/timeline/TimelineViewTest.kt | 65 +++++++++++++++++++ .../matrix/ui/messages/reply/InReplyToView.kt | 8 +-- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index fed2ab6577..f4ffc9c205 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -804,12 +804,12 @@ private fun MessageEventBubbleContent( .padding(top = topPadding, start = 8.dp, end = 8.dp) .clip(shape) - val talkbackCompatModifier = if (isTalkbackActive()) { + val talkbackCompatModifier = when { // Use z-index to make the replied to text being read after the message // Usually, you'd use traversalIndex for that, but it's not working for some reason - inReplyToModifier.zIndex(1f) - } else { - inReplyToModifier.clickable(onClick = inReplyToClick) + isTalkbackActive() -> inReplyToModifier.zIndex(1f) + inReplyTo is InReplyToDetails.Error -> inReplyToModifier + else -> inReplyToModifier.clickable(onClick = inReplyToClick) } val contentHasError = currentContentValidationState.hasError() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index bcddc37a74..f733f116d6 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -13,8 +13,12 @@ package io.element.android.features.messages.impl.timeline 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.onAllNodesWithText +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.v2.runAndroidComposeUiTest @@ -29,8 +33,14 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams @@ -168,6 +178,61 @@ class TimelineViewTest : RobolectricTest() { eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog) } + @Test + fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setTimelineView( + state = aTimelineState( + timelineItems = persistentListOf( + aTimelineItemEvent( + content = aTimelineItemTextContent(), + inReplyTo = InReplyToDetails.Ready( + eventId = AN_EVENT_ID, + senderId = A_USER_ID, + senderProfile = aProfileDetailsReady(), + eventContent = MessageContent( + body = "Replied message", + inReplyTo = null, + isEdited = false, + threadInfo = null, + type = TextMessageType("Replied message", null), + ), + textContent = "Replied message", + ), + ), + ), + eventSink = eventsRecorder, + ), + ) + eventsRecorder.clear() + + onAllNodesWithText("Replied message").onFirst().performClick() + eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID)) + } + + @Test + fun `a reply to an event which could not be loaded renders a placeholder and does not emit any Event when clicked`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setTimelineView( + state = aTimelineState( + timelineItems = persistentListOf( + aTimelineItemEvent( + content = aTimelineItemTextContent(), + inReplyTo = InReplyToDetails.Error(eventId = AN_EVENT_ID, message = "Failed to fetch the event"), + ), + ), + eventSink = eventsRecorder, + ), + onMessageClick = {}, + ) + eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0)) + eventsRecorder.clear() + + onNodeWithText("Failed to fetch the event").assertDoesNotExist() + onNodeWithText(activity!!.getString(CommonStrings.error_message_not_found)).assertIsDisplayed().performClick() + eventsRecorder.assertEmpty() + } + @Ignore( "performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." + "This needs to be reworked to use a different approach to check the LoadMore event was emitted." diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt index 3072c81a15..691031fcbe 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt @@ -77,7 +77,7 @@ fun InReplyToView( else -> ReplyToLoadingContent(modifier = modifier) } is InReplyToDetails.Error -> - ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier) + ReplyToErrorContent(maxLines = maxLines, modifier = modifier) is InReplyToDetails.Loading -> ReplyToLoadingContent(modifier = modifier) } @@ -150,7 +150,6 @@ private fun ReplyToLoadingContent( @Composable private fun ReplyToErrorContent( - data: InReplyToDetails.Error, maxLines: Int, modifier: Modifier = Modifier, ) { @@ -161,9 +160,10 @@ private fun ReplyToErrorContent( .padding(paddings) ) { Text( - text = data.message, + text = stringResource(id = CommonStrings.error_message_not_found), style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textCriticalPrimary, + fontStyle = FontStyle.Italic, + color = ElementTheme.colors.textSecondary, maxLines = maxLines, overflow = TextOverflow.Ellipsis, ) From 05528dbd3d9a9decbcbd647094c5f6d7930eb4f2 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:19:45 +0300 Subject: [PATCH 09/22] Let users enter their full Matrix user ID when searching for a homeserver The account provider search treated its whole input as a host, so a full user ID was probed as https://@alice:example.org, which never resolves, and the permissive URL fallback then offered that invalid address as a provider. Recognise a complete user ID and resolve the homeserver from its server name, preserving a port when there is one. Input that starts with @ but is not yet a complete user ID resolves nothing, so no candidate is produced while the user is still typing. Detection reuses MatrixPatterns.isUserId and UserId.domainName rather than a new pattern. --- .../login/impl/resolver/HomeserverResolver.kt | 11 +- .../impl/resolver/HomeserverResolverTest.kt | 108 ++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt index 8c6cab79ce..2700922678 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt @@ -14,6 +14,8 @@ import io.element.android.libraries.core.coroutine.parallelMap import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.isValidUrl import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow @@ -34,8 +36,13 @@ class HomeserverResolver( fun resolve(userInput: String): Flow> = flow { val flowContext = currentCoroutineContext() val trimmedUserInput = userInput.trim() - if (trimmedUserInput.length < 4) return@flow - val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/") + val searchTerm = when { + MatrixPatterns.isUserId(trimmedUserInput) -> UserId(trimmedUserInput).domainName.orEmpty() + trimmedUserInput.startsWith("@") -> return@flow + else -> trimmedUserInput + } + if (searchTerm.length < 4) return@flow + val candidateBase = searchTerm.ensureProtocol().removeSuffix("/") val list = getUrlCandidates(candidateBase) val currentList = Collections.synchronizedList(mutableListOf()) // Run all the requests in parallel diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt new file mode 100644 index 0000000000..38e04b6e7f --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt @@ -0,0 +1,108 @@ +/* + * 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.features.login.impl.resolver + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker +import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class HomeserverResolverTest { + @Test + fun `resolve - a server name is used as is`() = runTest { + val checkResult = lambdaRecorder> { Result.success(it == "https://example.org") } + val sut = createHomeserverResolver(checkResult) + sut.resolve("example.org").test { + assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org"))) + awaitComplete() + } + checkResult.assertions().isCalledExactly(1) + .withSequence( + listOf(value("https://example.org")), + ) + } + + @Test + fun `resolve - an url is used as is`() = runTest { + val checkResult = lambdaRecorder> { Result.success(it == "https://example.org") } + val sut = createHomeserverResolver(checkResult) + sut.resolve("https://example.org").test { + assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org"))) + awaitComplete() + } + checkResult.assertions().isCalledExactly(1) + .withSequence( + listOf(value("https://example.org")), + ) + } + + @Test + fun `resolve - a user id resolves the server name of the user id`() = runTest { + val checkResult = lambdaRecorder> { Result.success(it == "https://example.org") } + val sut = createHomeserverResolver(checkResult) + sut.resolve("@alice:example.org").test { + assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org"))) + awaitComplete() + } + checkResult.assertions().isCalledExactly(1) + .withSequence( + listOf(value("https://example.org")), + ) + } + + @Test + fun `resolve - a user id with a port resolves the server name of the user id`() = runTest { + val checkResult = lambdaRecorder> { Result.success(it == "https://example.org:8448") } + val sut = createHomeserverResolver(checkResult) + sut.resolve("@alice:example.org:8448").test { + assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org:8448"))) + awaitComplete() + } + checkResult.assertions().isCalledExactly(1) + .withSequence( + listOf(value("https://example.org:8448")), + ) + } + + @Test + fun `resolve - an incomplete user id does not resolve anything`() = runTest { + val checkResult = lambdaRecorder> { Result.success(true) } + val sut = createHomeserverResolver(checkResult) + sut.resolve("@alice").test { + awaitComplete() + } + sut.resolve("@alice:").test { + awaitComplete() + } + checkResult.assertions().isNeverCalled() + } + + @Test + fun `resolve - a user id with a too short server name does not resolve anything`() = runTest { + val checkResult = lambdaRecorder> { Result.success(true) } + val sut = createHomeserverResolver(checkResult) + sut.resolve("@alice:a.b").test { + awaitComplete() + } + checkResult.assertions().isNeverCalled() + } + + private fun TestScope.createHomeserverResolver( + checkResult: (String) -> Result, + ) = HomeserverResolver( + dispatchers = testCoroutineDispatchers(), + homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult), + localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), + ) +} From 6533a10ab9cdf19597733c0b312c1528fb6c4c5c Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:19:46 +0300 Subject: [PATCH 10/22] Keep manually revealed media visible when re-entering a room The set of revealed event ids lived on TimelineProtectionPresenter, which is scoped to the room graph. That graph is rebuilt every time the room is opened, so the set was empty on re-entry and every revealed image was hidden again. Move it into a session scoped TimelineProtectionStore. The store is in memory rather than persisted: revealing media is a privacy control and there is no hide action today, so keeping the reveals for the session leaves restarting the app as a way to undo one, and the set is dropped on logout. --- .../protection/TimelineProtectionPresenter.kt | 9 ++---- .../protection/TimelineProtectionStore.kt | 31 +++++++++++++++++++ .../TimelineProtectionPresenterTest.kt | 20 ++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt index 22be4acb2d..6110a2c4db 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @@ -20,7 +19,6 @@ import io.element.android.features.contentscanner.api.ContentScannerService import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.di.RoomScope -import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.media.isPreviewEnabled import io.element.android.libraries.matrix.api.room.BaseRoom @@ -32,9 +30,8 @@ class TimelineProtectionPresenter( private val mediaPreviewService: MediaPreviewService, private val room: BaseRoom, private val contentScannerService: ContentScannerService, + private val timelineProtectionStore: TimelineProtectionStore, ) : Presenter { - private val allowedEvents = mutableStateSetOf() - @Composable override fun present(): TimelineProtectionState { val mediaPreviewValue = remember { @@ -48,7 +45,7 @@ class TimelineProtectionPresenter( if (isPreviewEnabled) { ProtectionState.RenderAll } else { - ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet()) + ProtectionState.RenderOnly(eventIds = timelineProtectionStore.allowedEventIds.toImmutableSet()) } } } @@ -56,7 +53,7 @@ class TimelineProtectionPresenter( fun handleEvent(event: TimelineProtectionEvent) { when (event) { is TimelineProtectionEvent.ShowContent -> { - allowedEvents += setOfNotNull(event.eventId) + event.eventId?.let(timelineProtectionStore::allowEvent) } is TimelineProtectionEvent.ValidateContent -> { contentScannerService.scan(event.mediaSources, event.validationState) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt new file mode 100644 index 0000000000..fe0c753fac --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt @@ -0,0 +1,31 @@ +/* + * 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.features.messages.impl.timeline.protection + +import androidx.compose.runtime.mutableStateSetOf +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.EventId + +interface TimelineProtectionStore { + val allowedEventIds: Set + fun allowEvent(eventId: EventId) +} + +@ContributesBinding(SessionScope::class) +@SingleIn(SessionScope::class) +class DefaultTimelineProtectionStore : TimelineProtectionStore { + private val allowedEvents = mutableStateSetOf() + + override val allowedEventIds: Set = allowedEvents + + override fun allowEvent(eventId: EventId) { + allowedEvents += eventId + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt index 11142cc9a2..161b350c96 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt @@ -95,6 +95,24 @@ class TimelineProtectionPresenterTest { } } + @Test + fun `present - shown content is restored when the presenter is recreated`() = runTest { + val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Off, hideInviteAvatar = false) + val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig)) + val timelineProtectionStore = DefaultTimelineProtectionStore() + createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test { + val initialState = awaitItem() + assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf())) + initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID))) + } + createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test { + val initialState = awaitItem() + assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID))) + } + } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `present - validate media scans the media source`() = runTest { @@ -134,9 +152,11 @@ class TimelineProtectionPresenterTest { room: BaseRoom = FakeBaseRoom(), mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), contentScannerService: ContentScannerService = ContentScannerService { _: List, _: ContentValidationState -> }, + timelineProtectionStore: TimelineProtectionStore = DefaultTimelineProtectionStore(), ) = TimelineProtectionPresenter( mediaPreviewService = mediaPreviewService, room = room, contentScannerService = contentScannerService, + timelineProtectionStore = timelineProtectionStore, ) } From 798936111480300fa14cd42b9ed6eabf52f8a55b Mon Sep 17 00:00:00 2001 From: ElementBot Date: Sun, 9 Aug 2026 12:41:41 +0000 Subject: [PATCH 11/22] Update screenshots --- ...components_TimelineItemEventRowWithReplyOther_Day_1_en.png | 4 ++-- ...mponents_TimelineItemEventRowWithReplyOther_Night_1_en.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en.png index 7ddac02717..592e6327d6 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a4ac79f3b8fb4e231244bd96b244a4f2526d6ed28d558f9d50734d83bbb74992 -size 363909 +oid sha256:4c959c0a4f5890f5a04f911cc395816c23c5abce5c9baf1e569c302ff43206d5 +size 366838 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en.png index 4e17de6908..6761490630 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1e15400fa2c5ee6087316501445c551a3932b65e16de31e68acc5efbd16a559 -size 361603 +oid sha256:eb545091ad22e1c31c722d35a6492240c5a1fa66480613e37fc9c3817d8244a7 +size 364697 From aa7b9b87212a6d735d72664b1757e84ebcca5292 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:47:29 +0300 Subject: [PATCH 12/22] Trigger CI after recording screenshots From 19c6526841817dcc8ab79f0a5880345195b9dd0e Mon Sep 17 00:00:00 2001 From: Eklavya Jandial Date: Mon, 10 Aug 2026 13:10:12 +0530 Subject: [PATCH 13/22] [Task] Show active call participants in the people list #7199 (#7300) --- .../impl/members/RoomMemberListPresenter.kt | 46 ++++++++++++++----- .../impl/members/RoomMemberListState.kt | 1 + .../members/RoomMemberListStateProvider.kt | 8 +++- .../impl/members/RoomMemberListView.kt | 9 ++++ .../members/RoomMemberListPresenterTest.kt | 32 +++++++++++++ ...pl.members_RoomMemberListView_Day_2_en.png | 4 +- ...pl.members_RoomMemberListView_Day_4_en.png | 4 +- ....members_RoomMemberListView_Night_2_en.png | 4 +- ....members_RoomMemberListView_Night_4_en.png | 4 +- 9 files changed, 91 insertions(+), 21 deletions(-) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index f47f12f446..674dd9aad9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.toMatrixUser import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList @@ -58,6 +59,7 @@ class RoomMemberListPresenter( override fun present(): RoomMemberListState { val searchQuery = rememberTextFieldState() val membersState by room.membersStateFlow.collectAsState() + val roomInfo by room.roomInfoFlow.collectAsState() val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() } val roomModerationState = roomMembersModerationPresenter.present() @@ -72,13 +74,18 @@ class RoomMemberListPresenter( var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS) } var roomMembers: AsyncData by remember { mutableStateOf(AsyncData.Loading()) } var filteredRoomMembers: AsyncData by remember { mutableStateOf(AsyncData.Loading()) } + val activeCallParticipants = roomInfo.activeRoomCallParticipants + + val comparator = remember(activeCallParticipants) { + roomMemberListComparator(activeCallParticipants) + } // Update the room members when the screen is loaded LaunchedEffect(Unit) { room.updateMembers() } - LaunchedEffect(membersState, roomMemberIdentityStates) { + LaunchedEffect(membersState, roomMemberIdentityStates, activeCallParticipants) { if (membersState is RoomMembersState.Unknown) { return@LaunchedEffect } @@ -90,23 +97,23 @@ class RoomMemberListPresenter( } withContext(coroutineDispatchers.io) { val members = membersState.roomMembers().orEmpty().groupBy { it.membership } - val info = room.info() - if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < info.joinedMembersCount / 2) { + if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < roomInfo.joinedMembersCount / 2) { // Don't display initial room member list if we have less than half of the joined members: - // This result will come from the timeline loading membership events and it'll be wrong. + // This result will come from the timeline loading membership events, and it'll be wrong. return@withContext } val result = RoomMembers( invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()) - .map { it.withIdentityState(roomMemberIdentityStates) } + .map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) } .toImmutableList(), joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()) - .sortedWith(powerLevelRoomMemberComparator) - .map { it.withIdentityState(roomMemberIdentityStates) } + .sortedWith(comparator) + // .sortedWith(powerLevelRoomMemberComparator) + .map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) } .toImmutableList(), banned = members.getOrDefault(RoomMembershipState.BAN, emptyList()) .sortedBy { it.userId.value } - .map { it.withIdentityState(roomMemberIdentityStates) } + .map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) } .toImmutableList(), ) roomMembers = if (membersState is RoomMembersState.Pending) { @@ -150,12 +157,29 @@ class RoomMemberListPresenter( return state } - private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap): RoomMemberWithIdentityState { + private suspend fun RoomMember.withIdentityState( + identityStates: ImmutableMap, + activeCallParticipants: ImmutableList + ): RoomMemberWithIdentityState { return if (room.info().isEncrypted != true) { - RoomMemberWithIdentityState(this, null) + RoomMemberWithIdentityState(this, null, userId in activeCallParticipants) } else { val identityState = identityStates[userId] ?: encryptionService.getUserIdentity(userId, fallbackToServer = false).getOrNull() - RoomMemberWithIdentityState(this, identityState) + RoomMemberWithIdentityState(this, identityState, userId in activeCallParticipants) } } + + private fun roomMemberListComparator( + activeCallParticipants: List, + ): Comparator = + Comparator { a, b -> + val aInCall = a.userId in activeCallParticipants + val bInCall = b.userId in activeCallParticipants + + when { + aInCall && !bInCall -> -1 + !aInCall && bInCall -> 1 + else -> powerLevelRoomMemberComparator.compare(a, b) + } + } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt index 6d52db6c87..7b6744ef0b 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt @@ -66,4 +66,5 @@ data class RoomMembers( data class RoomMemberWithIdentityState( val roomMember: RoomMember, val identityState: IdentityState?, + val isInCall: Boolean ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt index 472a185076..6b1971fdae 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt @@ -78,7 +78,7 @@ private fun aLoadedRoomMembers() = AsyncData.Success( joined = persistentListOf( anAlice().withIdentity(identityState = IdentityState.Verified), aBob().withIdentity(identityState = IdentityState.PinViolation), - aCarol().withIdentity(), + aCarol().withIdentity(isInCall = true), aDavid().withIdentity(), anEve().withIdentity(identityState = IdentityState.VerificationViolation) ), @@ -174,4 +174,8 @@ fun aBannedSusie(): RoomMember = aRoomMember(UserId("@susie:server.org"), USER_N fun aBannedMallory(): RoomMember = aRoomMember(UserId("@mallory:server.org"), USER_NAME_MALLORY, membership = RoomMembershipState.BAN) -private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState) +private fun RoomMember.withIdentity(identityState: IdentityState? = null, isInCall: Boolean = false) = RoomMemberWithIdentityState( + this, + identityState, + isInCall +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt index 659d8ed0e0..33ca61b570 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt @@ -338,6 +338,15 @@ private fun RoomMemberListItem( color = ElementTheme.colors.textSecondary, ) } + + if (roomMemberWithIdentity.isInCall) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.VideoCallSolid(), + contentDescription = stringResource(CommonStrings.action_video_call), + tint = ElementTheme.colors.iconSuccessPrimary + ) + } } } ) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt index 48f0e791a3..0a88d2bf57 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt @@ -191,6 +191,38 @@ class RoomMemberListPresenterTest { awaitItem().eventSink(RoomMemberListEvent.RoomMemberSelected(anInvitedVictor())) } } + + @Test + fun `call participants are shown first in joined members`() = runTest { + val room = createFakeJoinedRoom().apply { + givenRoomInfo( + aRoomInfo( + joinedMembersCount = 5, + activeRoomCallParticipants = listOf( + aCarol().userId + ), + ) + ) + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + } + + val presenter = createPresenter(joinedRoom = room) + + presenter.test { + skipItems(1) + + val loadedState = awaitItem() + val joined = loadedState.filteredRoomMembers.dataOrNull()!!.joined + + assertThat(joined.first().roomMember.userId) + .isEqualTo(aCarol().userId) + + assertThat(joined.first().isInCall).isTrue() + + assertThat(joined.count { it.isInCall }).isEqualTo(1) + assertThat(joined.drop(1).all { !it.isInCall }).isTrue() + } + } } private fun createFakeJoinedRoom( diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_2_en.png index 54d2ed2f1d..51141ade27 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ceb07377e46e28001e0ce26daef88f65f4fd60be2debcf83672cf707163e76d7 -size 52992 +oid sha256:0015d03ba03fcca2ed52457efd13d87753be16d25fc7e54f0dd86d0d6b8ff77b +size 53289 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_4_en.png index 586e1de468..70b5529856 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:415488496b429f2fd1aa238b006e05198b0fc859286dd1cdd8ae7d8de1d3c3b3 -size 53851 +oid sha256:27e2e7ef3ec68ed0a339fe27bbe631b95597291517f26987929b44f924aed06a +size 54149 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_2_en.png index d4998f8375..814ac8df84 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d105ddd5bcc44641857376c227caf9124569fefc1b20b75d7a82e104055bd08 -size 53319 +oid sha256:7753e817d169d4e173cc234bf2a58ddbe8d29f45b164a1800025b38c1b83b77c +size 53569 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_4_en.png index 9e64d6b01e..d1f6b5493f 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3804c27513c81bf362309397e568cb57b2748aa4837364d0d30cfa28e07f9f7d -size 54155 +oid sha256:54909ba782c4ded91503b5b3b46a085f98a9480489a634c2ad1c88ffbf948083 +size 54404 From 09efb04a8f0451ffc031e6abe6042522d6ef1ef2 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:59:22 +0300 Subject: [PATCH 14/22] Hold the revealed event ids in a plain set The store no longer needs a mutableStateSetOf: an ordinary Set kept in a mutableStateOf does the same job, and matches DefaultMediaPreviewConfigStateStore. The Compose state itself has to stay. TimelineProtectionPresenter derives ProtectionState from the store inside a derivedStateOf, so the set must be a snapshot object for a reveal to invalidate it and reach the timeline. Backing it with a plain mutableSetOf leaves the presenter with nothing to observe and it stops emitting after ShowContent. --- .../impl/timeline/protection/TimelineProtectionStore.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt index fe0c753fac..019f4eabd6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt @@ -7,7 +7,7 @@ package io.element.android.features.messages.impl.timeline.protection -import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.mutableStateOf import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.libraries.di.SessionScope @@ -21,11 +21,11 @@ interface TimelineProtectionStore { @ContributesBinding(SessionScope::class) @SingleIn(SessionScope::class) class DefaultTimelineProtectionStore : TimelineProtectionStore { - private val allowedEvents = mutableStateSetOf() + private val allowedEvents = mutableStateOf(emptySet()) - override val allowedEventIds: Set = allowedEvents + override val allowedEventIds: Set get() = allowedEvents.value override fun allowEvent(eventId: EventId) { - allowedEvents += eventId + allowedEvents.value = allowedEvents.value + eventId } } From 829dbb1563ed0d082a5d822c200d57906f078139 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:16:20 +0000 Subject: [PATCH 15/22] Update zizmorcore/zizmor-action action to v0.6.2 (#7383) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e72ae0b4d1..ade8418ac1 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -336,7 +336,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 upload_reports: name: Project Check Suite From 1693d325c16b0135a4a69837043f9d354aa83d8c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 10 Aug 2026 12:12:55 +0200 Subject: [PATCH 16/22] Update UI according to https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5781-5862 --- .../components/TimelineItemEventRow.kt | 2 +- .../contentvalidation/ContentErrorView.kt | 16 ++++++----- .../matrix/ui/messages/reply/InReplyToView.kt | 27 +++++++------------ 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index f4ffc9c205..519b72d17b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -812,7 +812,7 @@ private fun MessageEventBubbleContent( else -> inReplyToModifier.clickable(onClick = inReplyToClick) } - val contentHasError = currentContentValidationState.hasError() + val contentHasError = currentContentValidationState.hasError() || inReplyTo is InReplyToDetails.Error val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault Box( diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentErrorView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentErrorView.kt index 24f4a15200..52d81ae895 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentErrorView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentErrorView.kt @@ -27,7 +27,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons @Composable internal fun ContentErrorView( title: String, - message: String, + message: String?, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues.Zero, onTextLayout: ((TextLayoutResult) -> Unit)? = null, @@ -51,12 +51,14 @@ internal fun ContentErrorView( color = ElementTheme.colors.textCriticalPrimary, style = ElementTheme.typography.fontBodyMdMedium, ) - Text( - text = message, - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodySmRegular, - onTextLayout = onTextLayout, - ) + if (message != null) { + Text( + text = message, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + onTextLayout = onTextLayout, + ) + } } } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt index 691031fcbe..45a9ef2427 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -45,6 +44,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentErrorView import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView @@ -77,7 +77,7 @@ fun InReplyToView( else -> ReplyToLoadingContent(modifier = modifier) } is InReplyToDetails.Error -> - ReplyToErrorContent(maxLines = maxLines, modifier = modifier) + ReplyToErrorContent(modifier = modifier) is InReplyToDetails.Loading -> ReplyToLoadingContent(modifier = modifier) } @@ -150,24 +150,15 @@ private fun ReplyToLoadingContent( @Composable private fun ReplyToErrorContent( - maxLines: Int, modifier: Modifier = Modifier, ) { - val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp) - Row( - modifier - .background(ElementTheme.colors.bgCanvasDefault) - .padding(paddings) - ) { - Text( - text = stringResource(id = CommonStrings.error_message_not_found), - style = ElementTheme.typography.fontBodyMdRegular, - fontStyle = FontStyle.Italic, - color = ElementTheme.colors.textSecondary, - maxLines = maxLines, - overflow = TextOverflow.Ellipsis, - ) - } + ContentErrorView( + title = stringResource(CommonStrings.error_message_not_found), + message = null, + modifier = modifier, + contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp), + onTextLayout = null, + ) } @Composable From f7936b92d049f1edde2cf2acb5ae6da80287a2ac Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 10 Aug 2026 10:31:57 +0000 Subject: [PATCH 17/22] Update screenshots --- ...components_TimelineItemEventRowWithReplyOther_Day_1_en.png | 4 ++-- ...mponents_TimelineItemEventRowWithReplyOther_Night_1_en.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en.png index 592e6327d6..40fa5d563f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c959c0a4f5890f5a04f911cc395816c23c5abce5c9baf1e569c302ff43206d5 -size 366838 +oid sha256:3ff97eb99daf65f74b469d51b93bd77d304d0c780b9406e1c4d7893b71e3c68d +size 367163 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en.png index 6761490630..2348755afd 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb545091ad22e1c31c722d35a6492240c5a1fa66480613e37fc9c3817d8244a7 -size 364697 +oid sha256:e907f67ef884edb61df187f0f229ed4ff4cb31f8c276f807698f861c0e7a4c27 +size 364124 From 1b833df7270701f1f1913890d5b6409823777734 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:36:36 +0300 Subject: [PATCH 18/22] Expose the revealed event ids as a SnapshotStateSet Restore the snapshot backed set the store started with and declare allowedEventIds as SnapshotStateSet so the interface says out loud that reads of it are observable. TimelineProtectionPresenter reads the set inside derivedStateOf, so a plain set silently stops the timeline from re-rendering when an event is revealed. Naming the type keeps that requirement from being mistaken for an implementation detail. --- .../timeline/protection/TimelineProtectionStore.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt index 019f4eabd6..aa6655cb86 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt @@ -7,25 +7,26 @@ package io.element.android.features.messages.impl.timeline.protection -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.snapshots.SnapshotStateSet import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.EventId interface TimelineProtectionStore { - val allowedEventIds: Set + val allowedEventIds: SnapshotStateSet fun allowEvent(eventId: EventId) } @ContributesBinding(SessionScope::class) @SingleIn(SessionScope::class) class DefaultTimelineProtectionStore : TimelineProtectionStore { - private val allowedEvents = mutableStateOf(emptySet()) + private val allowedEvents = mutableStateSetOf() - override val allowedEventIds: Set get() = allowedEvents.value + override val allowedEventIds: SnapshotStateSet = allowedEvents override fun allowEvent(eventId: EventId) { - allowedEvents.value = allowedEvents.value + eventId + allowedEvents += eventId } } From e10196381492107be5e67ac4e847aafa55a143fc Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 10 Aug 2026 15:38:57 +0200 Subject: [PATCH 19/22] Display 'add reactions' menu in the threads screen (#7388) --- .../messages/impl/threads/ThreadedMessagesNode.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index b56268eb02..b7fd9f572f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -28,6 +28,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.MessagesEvent import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.MessagesPresenter import io.element.android.features.messages.impl.MessagesState @@ -40,6 +41,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -54,6 +56,7 @@ import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -91,6 +94,7 @@ class ThreadedMessagesNode( private val permalinkParser: PermalinkParser, private val appNavigationStateService: AppNavigationStateService, private val roomMemberModerationRenderer: RoomMemberModerationRenderer, + private val emojiPickerRenderer: EmojiPickerRenderer, ) : Node(buildContext, plugins = plugins), MessagesNavigator { data class Inputs( val threadRootEventId: ThreadId, @@ -322,7 +326,15 @@ class ThreadedMessagesNode( onViewAllPinnedMessagesClick = {}, modifier = modifier, knockRequestsBannerView = {}, - customReactionBottomSheet = {}, + customReactionBottomSheet = { + CustomReactionBottomSheet( + state = state.customReactionState, + onSelectEmoji = { uniqueId, emoji -> + state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId)) + }, + emojiPickerRenderer = emojiPickerRenderer, + ) + }, onThreadsListClick = {}, ) From 481407329fd955081a6b3043a647b91d4b1268e3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:03:45 +0200 Subject: [PATCH 20/22] Update actions/setup-java action to v5.7.0 (#7339) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- .github/workflows/build_enterprise.yml | 2 +- .github/workflows/generate_github_pages.yml | 2 +- .github/workflows/gradle-wrapper-update.yml | 2 +- .github/workflows/maestro-local.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/nightlyReports.yml | 4 ++-- .github/workflows/quality.yml | 12 ++++++------ .github/workflows/recordScreenshots.yml | 2 +- .github/workflows/release.yml | 6 +++--- .github/workflows/sonar.yml | 2 +- .github/workflows/sync-localazy.yml | 2 +- .github/workflows/tests.yml | 2 +- 13 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69152b7e2d..252e365dce 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,7 +51,7 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml index f2658bb342..58db7a7c84 100644 --- a/.github/workflows/build_enterprise.yml +++ b/.github/workflows/build_enterprise.yml @@ -56,7 +56,7 @@ jobs: - name: Clone submodules run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml index 45e886f904..7b5271aeaf 100644 --- a/.github/workflows/generate_github_pages.yml +++ b/.github/workflows/generate_github_pages.yml @@ -19,7 +19,7 @@ jobs: - name: ⏬ Checkout with LFS uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/gradle-wrapper-update.yml b/.github/workflows/gradle-wrapper-update.yml index 6b64a731d7..c64ab731a3 100644 --- a/.github/workflows/gradle-wrapper-update.yml +++ b/.github/workflows/gradle-wrapper-update.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 name: Use JDK 21 with: distribution: 'temurin' # See 'Supported distributions' for available options diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index 198b462349..6b60aaab04 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -44,7 +44,7 @@ jobs: # https://github.com/actions/checkout/issues/881 ref: ${{ github.ref }} persist-credentials: false - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 name: Use JDK 21 with: distribution: 'temurin' # See 'Supported distributions' for available options diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9beab4e01c..67c94da9f6 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -36,7 +36,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index 23cfffc265..14fbf6a98b 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -37,7 +37,7 @@ jobs: uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -80,7 +80,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index ade8418ac1..1fa9c9c46f 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -69,7 +69,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -108,7 +108,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -149,7 +149,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -183,7 +183,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -228,7 +228,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -269,7 +269,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index 00502d9612..bba96afc6c 100644 --- a/.github/workflows/recordScreenshots.yml +++ b/.github/workflows/recordScreenshots.yml @@ -54,7 +54,7 @@ jobs: with: persist-credentials: false - name: ☕️ Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bc71a02ff..0099f52b92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -82,7 +82,7 @@ jobs: - name: Clone submodules run: git submodule update --init --recursive - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -126,7 +126,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 6d9a62a2d1..a18588ed69 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -45,7 +45,7 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index 61b4a8d521..2d4d2cb904 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -17,7 +17,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5ee7f5065d..9638ed03dc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,7 +63,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: ☕️ Use JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' From cb3b9e794641148acf574611d00f5f97dc2a1739 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 10 Aug 2026 16:16:14 +0200 Subject: [PATCH 21/22] Add default `OAUTH_CLIENT_URL_PATH` alternative (#7385) * Add default `OAUTH_CLIENT_URL_PATH` alternative Previously this was `null`, but we want to use `apps/android` instead to identify Element X Android. Element Pro will use a different value, and other custom apps based on EP will use `null` or their own custom value, if they provided one * Suppress lint check so the pro app can be built --- .../io/element/android/libraries/matrix/api/auth/OAuthConfig.kt | 2 ++ plugins/src/main/kotlin/config/BuildTimeConfig.kt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OAuthConfig.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OAuthConfig.kt index 3256d0545c..b3ee0878d5 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OAuthConfig.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OAuthConfig.kt @@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.api.auth import io.element.android.libraries.matrix.api.BuildConfig object OAuthConfig { + // This *can* be null if the build time config field is not set + @Suppress("UNNECESSARY_SAFE_CALL") val CLIENT_URI = BuildConfig.OAUTH_CLIENT_URI_PATH?.let { // Concatenate the base URI and the path, ensuring there is exactly one '/' between them "${BuildConfig.CLIENT_URI.trimEnd('/')}/${it.trimStart('/')}" diff --git a/plugins/src/main/kotlin/config/BuildTimeConfig.kt b/plugins/src/main/kotlin/config/BuildTimeConfig.kt index 9c8c5c986a..83236f1a5a 100644 --- a/plugins/src/main/kotlin/config/BuildTimeConfig.kt +++ b/plugins/src/main/kotlin/config/BuildTimeConfig.kt @@ -22,7 +22,7 @@ object BuildTimeConfig { val URL_ACCEPTABLE_USE: String? = null val URL_PRIVACY: String? = null val URL_POLICY: String? = null - val OAUTH_CLIENT_URL_PATH: String? = null + val OAUTH_CLIENT_URL_PATH: String? = "apps/android" val SERVICES_MAPTILER_BASE_URL: String? = null val SERVICES_MAPTILER_APIKEY: String? = null val SERVICES_MAPTILER_LIGHT_MAPID: String? = null From 348c7bcea8ca5156b96d96d77b25e5a4a049bd5f Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:35:38 +0300 Subject: [PATCH 22/22] Expand the emoji picker to full height when the keyboard is shown (#7382) * Expand the emoji picker to full height when the keyboard is shown The picker content is rendered with fillMaxSize, so the sheet measures itself to the full window height. That makes the partially expanded anchor available, and the modal sheet auto-shows at that anchor, half way down the screen. The sheet reserves the IME inset through its content window insets, but applies that padding at its own bottom edge, which is off-screen while it rests half way down. The band that is both on-screen and above the keyboard is then only about a tenth of the screen, which is why the matching emoji end up behind it. Expand the sheet while the keyboard is visible so its bottom edge returns to the screen edge and the inset it already reserves lands on top of the keyboard. Modifier.imePadding on the sheet content is not an option: the sheet applies the insets with windowInsetsPadding, which consumes them, so a nested imePadding reads zero. * Cover the keyboard driven sheet expansion with a test The IME reaction moves into ExpandSheetWhenImeIsVisible so a Robolectric test can drive it: dispatching window insets to the composition flips WindowInsets.isImeVisible, and the sheet state is observable from the test. Both the shown and the hidden case are asserted, which is the patch coverage this change was missing. * Do not leave a blank strip below the expanded emoji search The 10dp of bottom padding on the search bar separates the collapsed field from the category tabs underneath it. It stayed applied once the search bar expanded to fill the picker, so the picker stopped 10dp short of its own bottom edge. That strip used to sit below the visible part of a half height sheet. Now that the sheet expands against the keyboard it reads as a gap between the results and the keyboard, so only pad while collapsed. --- .../CustomReactionBottomSheet.kt | 17 +++++ .../ExpandSheetWhenImeIsVisibleTest.kt | 62 ++++++++++++++++ libraries/emoji/impl/build.gradle.kts | 2 +- .../emoji/impl/picker/EmojiPickerView.kt | 2 +- .../emoji/impl/picker/EmojiPickerViewTest.kt | 72 +++++++++++++++++++ 5 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ExpandSheetWhenImeIsVisibleTest.kt create mode 100644 libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerViewTest.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt index c90a0e5acc..a14d3b3893 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt @@ -8,11 +8,16 @@ package io.element.android.features.messages.impl.timeline.components.customreaction +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.isImeVisible import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import io.element.android.emojibasebindings.Emoji @@ -54,6 +59,7 @@ fun CustomReactionBottomSheet( modifier = modifier, scrollable = false, ) { + ExpandSheetWhenImeIsVisible(sheetState) emojiPickerRenderer.Render( state = state.target.emojiPickerState, onSelectEmoji = ::onEmojiSelectedDismiss, @@ -67,3 +73,14 @@ fun CustomReactionBottomSheet( } } } + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +internal fun ExpandSheetWhenImeIsVisible(sheetState: SheetState) { + val isImeVisible = WindowInsets.isImeVisible + LaunchedEffect(isImeVisible) { + if (isImeVisible) { + sheetState.expand() + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ExpandSheetWhenImeIsVisibleTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ExpandSheetWhenImeIsVisibleTest.kt new file mode 100644 index 0000000000..3f5805a0b4 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ExpandSheetWhenImeIsVisibleTest.kt @@ -0,0 +1,62 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalTestApi::class) + +package io.element.android.features.messages.impl.timeline.components.customreaction + +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class ExpandSheetWhenImeIsVisibleTest : RobolectricTest() { + @Test + fun `the sheet is left alone while the keyboard is hidden`() = runAndroidComposeUiTest { + val sheetState = renderWithIme(isImeVisible = false) + assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) + } + + @Test + fun `the sheet is expanded when the keyboard is shown`() = runAndroidComposeUiTest { + val sheetState = renderWithIme(isImeVisible = true) + assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) + } +} + +private fun AndroidComposeUiTest.renderWithIme(isImeVisible: Boolean): SheetState { + lateinit var sheetState: SheetState + lateinit var view: View + setContent { + view = LocalView.current + sheetState = rememberBottomSheetState(initialValue = SheetValue.PartiallyExpanded) + ExpandSheetWhenImeIsVisible(sheetState) + } + runOnIdle { + ViewCompat.dispatchApplyWindowInsets( + view, + WindowInsetsCompat.Builder() + .setVisible(WindowInsetsCompat.Type.ime(), isImeVisible) + .setInsets(WindowInsetsCompat.Type.ime(), if (isImeVisible) Insets.of(0, 0, 0, 300) else Insets.NONE) + .build() + ) + } + waitForIdle() + return sheetState +} diff --git a/libraries/emoji/impl/build.gradle.kts b/libraries/emoji/impl/build.gradle.kts index 1cb274e6c0..59a1ad6575 100644 --- a/libraries/emoji/impl/build.gradle.kts +++ b/libraries/emoji/impl/build.gradle.kts @@ -36,5 +36,5 @@ dependencies { implementation(libs.matrix.emojibase.bindings) testImplementation(projects.libraries.matrix.test) - testCommonDependencies(libs) + testCommonDependencies(libs, includeTestComposeView = true) } diff --git a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt index 94eaea1595..88faa3a19a 100644 --- a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt @@ -62,7 +62,7 @@ internal fun EmojiPickerView( Column(modifier) { SearchBar( - modifier = Modifier.padding(bottom = 10.dp), + modifier = Modifier.padding(bottom = if (state.isSearchActive) 0.dp else 10.dp), queryState = state.searchQuery, resultState = state.searchResults, active = state.isSearchActive, diff --git a/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerViewTest.kt b/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerViewTest.kt new file mode 100644 index 0000000000..4c0d7949cb --- /dev/null +++ b/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerViewTest.kt @@ -0,0 +1,72 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.libraries.emoji.impl.picker + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsEqualTo +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import androidx.compose.ui.unit.dp +import io.element.android.emojibasebindings.Emoji +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test +import org.robolectric.annotation.Config + +private val PICKER_HEIGHT = 600.dp + +class EmojiPickerViewTest : RobolectricTest() { + @Config(qualifiers = "w400dp-h800dp") + @Test + fun `the search results reach the bottom of the picker`() = runAndroidComposeUiTest { + setEmojiPickerView() + onNode(hasScrollToIndexAction()) + .getBoundsInRoot() + .bottom + .assertIsEqualTo(PICKER_HEIGHT, "emoji grid bottom") + } +} + +private fun AndroidComposeUiTest.setEmojiPickerView() { + setContent { + Box(Modifier.size(400.dp, PICKER_HEIGHT)) { + EmojiPickerView( + state = aDefaultEmojiPickerState( + isSearchActive = true, + searchQuery = "smile", + searchResults = SearchBarResultState.Results( + persistentListOf( + Emoji( + hexcode = "0x00", + label = "grinning face", + tags = persistentListOf("grinning"), + shortcodes = persistentListOf("smile"), + unicode = "😀", + skins = null, + ), + ) + ), + ), + onSelectEmoji = {}, + selectedEmojis = persistentSetOf(), + modifier = Modifier.fillMaxSize(), + ) + } + } +}