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 e72ae0b4d1..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' @@ -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 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' 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/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..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 @@ -56,6 +56,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import timber.log.Timber @@ -102,6 +103,7 @@ class LinkNewDeviceFlowNode( @Suppress("AssignedValueIsNeverRead") linkDesktopHandlerJob = observeLinkNewDesktopHandler() }, + onResume = ::onResume, onDestroy = { linkMobileHandlerJob?.cancel() linkDesktopHandlerJob?.cancel() @@ -209,6 +211,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.value 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/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(), + ) +} 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 = {}, ) 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 f52a70064b..b0be2dde88 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 @@ -801,15 +801,15 @@ 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() + 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/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/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..aa6655cb86 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt @@ -0,0 +1,32 @@ +/* + * 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 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: SnapshotStateSet + fun allowEvent(eventId: EventId) +} + +@ContributesBinding(SessionScope::class) +@SingleIn(SessionScope::class) +class DefaultTimelineProtectionStore : TimelineProtectionStore { + private val allowedEvents = mutableStateSetOf() + + override val allowedEventIds: SnapshotStateSet = allowedEvents + + override fun allowEvent(eventId: EventId) { + allowedEvents += eventId + } +} 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/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/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, ) } 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce602f0e93..d7349f35fd 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" @@ -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" 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(), + ) + } + } +} 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/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..f4f17fb9a7 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt @@ -0,0 +1,36 @@ +/* + * 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 timber.log.Timber +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() + }.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") + } + } +} 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/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 ce516ecd54..31a8f5c646 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 @@ -40,8 +40,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, ) { @@ -66,7 +66,7 @@ fun CheckableUserRow( } } Checkbox( - onCheckedChange = onCheckedChange, + onCheckedChange = null, checked = checked, enabled = enabled, ) 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 3072c81a15..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(data = inReplyTo, maxLines = maxLines, modifier = modifier) + ReplyToErrorContent(modifier = modifier) is InReplyToDetails.Loading -> ReplyToLoadingContent(modifier = modifier) } @@ -150,24 +150,15 @@ private fun ReplyToLoadingContent( @Composable private fun ReplyToErrorContent( - data: InReplyToDetails.Error, - maxLines: Int, modifier: Modifier = Modifier, ) { - val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp) - Row( - modifier - .background(ElementTheme.colors.bgCanvasDefault) - .padding(paddings) - ) { - Text( - text = data.message, - style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textCriticalPrimary, - 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 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 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) + } +} 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 73b7e674e2..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:991deacdfb7bc371a2df960a2364ae3b2187e588aa5afe7f39409e367040dd9c -size 363893 +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 b6e4650d9d..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:2cd357a1fbaa425eca95587bff2f768a45e773f983f75664875653ba1900e534 -size 361625 +oid sha256:e907f67ef884edb61df187f0f229ed4ff4cb31f8c276f807698f861c0e7a4c27 +size 364124 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 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