diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2bc1f10978..aa9be929ea 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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 00d5bb7fb5..b1d7204168 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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 3c9b6f9dd8..763936e3d6 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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 a122135f37..777e9f1173 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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 c06dc55fa0..1195c7b53d 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -47,7 +47,7 @@ jobs: # https://github.com/actions/checkout/issues/881 ref: ${{ github.ref }} persist-credentials: false - - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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 250f736f65..25e7d174e0 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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 c4e70e84ec..fe38c0138f 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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 5f0c2b1b3a..1f6289a8ea 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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 b445301596..efac08a99f 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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 f761e00438..8f264aa6de 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -88,7 +88,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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 93a5e371b6..4febf272a9 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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 7445bcc45b..9065e6d4e9 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -20,7 +20,7 @@ jobs: with: persist-credentials: false - name: Use JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 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 bbf8fdf811..e0ce28d5ee 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@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' @@ -78,6 +78,9 @@ jobs: - name: ⚙️ Run screenshot tests run: ./gradlew :tests:uitests:verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + - name: ⚙️ Run Compound screenshot tests + run: ./gradlew :libraries:compound:verifyRoborazziDebug $CI_GRADLE_ARG_PROPERTIES + - name: ⚙️ Generate kover coverage report run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt index 7432a6904f..a04abe1417 100644 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt @@ -9,15 +9,8 @@ package io.element.android.appconfig object AuthenticationConfig { - const val MATRIX_ORG_URL = "https://matrix.org" - /** * URL with some docs that explain what's sliding sync and how to add it to your home server. */ const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md" - - /** - * Force a sliding sync proxy url, if not null, the proxy url in the .well-known file will be ignored. - */ - val SLIDING_SYNC_PROXY_URL: String? = null } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index c9deaeb3b3..5f2dc837ad 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -52,11 +52,11 @@ import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.waitForChildAttached -import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId @@ -410,7 +410,8 @@ class RootFlowNode( } private suspend fun onLoginLink(params: LoginParams) { - if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) { + val accountProvider = AccountProvider.Generic(params.accountProvider) + if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(accountProvider)) { // Is there a session already? val sessions = sessionStore.getAllSessions() if (sessions.isNotEmpty()) { diff --git a/build.gradle.kts b/build.gradle.kts index 876383e325..725033a6ea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -153,7 +153,10 @@ allprojects { maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) } + val isRoborazziTest = project.gradle.startParameter.taskNames.any { it.contains("roborazzi", ignoreCase = true) } if (isScreenshotTest) { + // Paparazzi tests benefit from parallelisation, so we can use half the available cores to run them in parallel. + maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) // Increase heap size for screenshot tests maxHeapSize = "2g" // Record all the languages? @@ -165,9 +168,22 @@ allprojects { exclude("translations/*.class") } } else { + // Robolectric and Compose pay a 5 to 30 seconds bootstrap cost per test JVM (instrumenting + // android-all, then warming up the Compose runtime). That cost is paid once per JVM and then + // amortised over every test class the JVM runs, so splitting a module across several forks + // re-pays it for each fork instead of saving time. Keep a single fork per module and let + // Gradle parallelise by running many modules' test tasks concurrently instead. + maxParallelForks = 1 + // Disable screenshot tests by default exclude("ui/*.class") exclude("translations/*.class") + if (isRoborazziTest.not()) { + // Roborazzi screenshot tests (:libraries:compound) live in a `screenshot` package, which + // the two patterns above do not match, so they used to run on every plain unit test run. + // They are verified by the dedicated `verifyRoborazziDebug` task instead. + exclude("**/screenshot/**") + } } } } diff --git a/enterprise b/enterprise index f463003f68..6b422f4c8e 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit f463003f6813ad33fc00f80786bc83b6a7f0d8e2 +Subproject commit 6b422f4c8e9e9e5557056e8a861fe3bfd9338f24 diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt index 2cb4a356be..5483d1e60e 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt @@ -11,6 +11,7 @@ package io.element.android.features.enterprise.api import androidx.compose.ui.graphics.Color import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.libraries.matrix.api.ClientUrlContentFetcher +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.Flow @@ -36,18 +37,21 @@ interface EnterpriseService { suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String /** - * Returns the list of homeservers the user is allowed to sign in to. - * - * If the list is empty or contains the special value [ANY_ACCOUNT_PROVIDER], the user is allowed to sign in to any homeserver. + * Returns the list of account provider the user is allowed to sign in to. */ - fun homeserverAllowList(): List + fun accountProviderAllowList(): List /** - * Whether the user is allowed to sign in to a given homeserver, according to [homeserverAllowList]. - * - * @param homeserverUrl the server the user is trying to use. + * Whether the user is allowed to sign in to any account provider. */ - suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean + fun canConnectToAnyAccountProvider(): Boolean + + /** + * Whether the user is allowed to sign in to a given homeserver, according to [accountProviderAllowList]. + * + * @param accountProvider the account provider the user is trying to use. + */ + suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean /** * Whether the given homeserver enforces the use of Element Pro or a derived app. @@ -97,14 +101,4 @@ interface EnterpriseService { * @param sessionId the session whose channel is requested. */ fun getNoisyNotificationChannelId(sessionId: SessionId): String? - - companion object { - const val ANY_ACCOUNT_PROVIDER = "*" - } -} - -fun EnterpriseService.canConnectToAnyHomeserver(): Boolean { - return homeserverAllowList().let { - it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER) - } } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index 6ccf70cd02..02fa9b282c 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -19,6 +19,7 @@ import io.element.android.libraries.core.extensions.mapCatchingExceptions import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -33,8 +34,9 @@ class DefaultEnterpriseService( ) : EnterpriseService { override suspend fun isEnterpriseUser(sessionId: SessionId) = false override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url - override fun homeserverAllowList(): List = emptyList() - override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true + override fun accountProviderAllowList(): List = emptyList() + override fun canConnectToAnyAccountProvider(): Boolean = true + override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider) = true override suspend fun isElementProEnforced(serverName: String): Boolean { val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return false } return temporaryMatrixClient.use { client -> diff --git a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt index 026c9caf9e..f43284e0bd 100644 --- a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt +++ b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt @@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClient import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory +import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderGeneric import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.test.runTest @@ -26,15 +27,21 @@ import org.junit.Test class DefaultEnterpriseServiceTest { @Test - fun homeserverWhitelist() { + fun accountProviderAllowList() { val defaultEnterpriseService = createDefaultEnterpriseService() - assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty() + assertThat(defaultEnterpriseService.accountProviderAllowList()).isEmpty() } @Test - fun `isAllowedToConnectToHomeserver is true for all homeserver urls`() = runTest { + fun `canConnectToAnyAccountProvider is always true`() { val defaultEnterpriseService = createDefaultEnterpriseService() - assertThat(defaultEnterpriseService.isAllowedToConnectToHomeserver(A_HOMESERVER_URL)).isTrue() + assertThat(defaultEnterpriseService.canConnectToAnyAccountProvider()).isTrue() + } + + @Test + fun `isAllowedToConnectToAccountProvider is true for all account providers`() = runTest { + val defaultEnterpriseService = createDefaultEnterpriseService() + assertThat(defaultEnterpriseService.isAllowedToConnectToAccountProvider(anAccountProviderGeneric())).isTrue() } @Test diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt index a8efcb5873..3ca30d410d 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -13,6 +13,7 @@ import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.matrix.api.ClientUrlContentFetcher +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask @@ -22,8 +23,9 @@ import kotlinx.coroutines.flow.asStateFlow class FakeEnterpriseService( private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() }, - private val defaultHomeserverListResult: () -> List = { emptyList() }, - private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() }, + private val accountProviderAllowListResult: () -> List = { emptyList() }, + private val canConnectToAnyAccountProviderResult: () -> Boolean = { true }, + private val isAllowedToConnectToAccountProviderResult: (AccountProvider) -> Boolean = { lambdaError() }, initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default, initialBrandColor: Color? = null, private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() }, @@ -44,12 +46,16 @@ class FakeEnterpriseService( tweakMasUrlResult(url, urlContentFetcher) } - override fun homeserverAllowList(): List { - return defaultHomeserverListResult() + override fun accountProviderAllowList(): List { + return accountProviderAllowListResult() } - override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean = simulateLongTask { - isAllowedToConnectToHomeserverResult(homeserverUrl) + override fun canConnectToAnyAccountProvider(): Boolean { + return canConnectToAnyAccountProviderResult() + } + + override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean = simulateLongTask { + isAllowedToConnectToAccountProviderResult(accountProvider) } override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index 9bc8719a8b..5245a164d1 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -91,7 +91,6 @@ import org.junit.Rule import org.junit.Test import kotlin.time.Duration.Companion.seconds -@Suppress("LargeClass") class RoomListPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt b/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt index 39cfc3afba..30d6eaf844 100644 --- a/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt +++ b/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt @@ -59,7 +59,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -@Suppress("LargeClass") internal class DefaultInvitePeoplePresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt index 25f7765a7c..a1ab73486d 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt @@ -107,6 +107,7 @@ sealed interface LoadedDetails { ) : LoadedDetails } +@Immutable sealed interface JoinAuthorisationStatus { data object None : JoinAuthorisationStatus data class IsInvited(val inviteData: InviteData, val inviteSender: InviteSender?) : JoinAuthorisationStatus diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt index 3c104e47ae..3edaf649e4 100644 --- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt @@ -70,7 +70,6 @@ import org.junit.Rule import org.junit.Test import java.util.Optional -@Suppress("LargeClass") class JoinRoomPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/login/api/build.gradle.kts b/features/login/api/build.gradle.kts index 2a4e3ecb7c..a0683c814e 100644 --- a/features/login/api/build.gradle.kts +++ b/features/login/api/build.gradle.kts @@ -16,4 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) } diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt index 0c35c1655f..55cfa4b1ba 100644 --- a/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt @@ -8,6 +8,8 @@ package io.element.android.features.login.api.accesscontrol +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider + /** * Enforces the restriction an enterprise deployment can put on which account providers the user may sign in to. */ @@ -15,7 +17,7 @@ interface AccountProviderAccessControl { /** * Whether sign-in to this provider is permitted; `true` on a build with no such restriction. * - * @param accountProviderUrl the server the user is trying to use. + * @param accountProvider the account provider the user is trying to use. */ - suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean + suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt index a3a475df2f..58fbeed4bc 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt @@ -14,16 +14,16 @@ import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.IsEnterpriseBuild import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.impl.changeserver.AccountProviderAccessException +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider @ContributesBinding(AppScope::class) class DefaultAccountProviderAccessControl( private val isEnterpriseBuild: IsEnterpriseBuild, private val enterpriseService: EnterpriseService, ) : AccountProviderAccessControl { - override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try { + override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider) = try { assertIsAllowedToConnectToAccountProvider( - title = accountProviderUrl, - accountProviderUrl = accountProviderUrl, + accountProvider = accountProvider, ) true } catch (_: AccountProviderAccessException) { @@ -32,22 +32,22 @@ class DefaultAccountProviderAccessControl( @Throws(AccountProviderAccessException::class) suspend fun assertIsAllowedToConnectToAccountProvider( - title: String, - accountProviderUrl: String, + accountProvider: AccountProvider, ) { if (isEnterpriseBuild().not()) { // Ensure that Element Pro is not required for this account provider - if (enterpriseService.isElementProEnforced(accountProviderUrl)) { + if (enterpriseService.isElementProEnforced(accountProvider.serverNameOrBaseUrl())) { throw AccountProviderAccessException.NeedElementProException( - unauthorisedAccountProviderTitle = title, + unauthorisedAccountProviderTitle = accountProvider.friendlyServerName(), applicationId = ELEMENT_PRO_APPLICATION_ID, ) } } - if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) { + if (enterpriseService.isAllowedToConnectToAccountProvider(accountProvider).not()) { throw AccountProviderAccessException.UnauthorizedAccountProviderException( - unauthorisedAccountProviderTitle = title, - authorisedAccountProviderTitles = enterpriseService.homeserverAllowList(), + unauthorisedAccountProviderTitle = accountProvider.friendlyServerName(), + authorisedAccountProviderTitles = enterpriseService.accountProviderAllowList() + .map { it.friendlyServerName() }, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt deleted file mode 100644 index 7ca467da7d..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.login.impl.accountprovider - -data class AccountProvider( - val url: String, - val title: String = url.removePrefix("https://").removePrefix("http://"), - val subtitle: String? = null, - val isPublic: Boolean = false, - val isMatrixOrg: Boolean = false, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt index 6d85f2c5ff..3e8e7657a4 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt @@ -11,10 +11,10 @@ package io.element.android.features.login.impl.accountprovider import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn -import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.enterprise.api.canConnectToAnyHomeserver import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider +import io.element.android.libraries.matrix.api.accountprovider.matrixOrgAccountProvider import io.element.android.libraries.preferences.api.store.AppPreferencesStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -33,11 +33,9 @@ class AccountProviderDataSource( ) { // The provider used when the user has not selected one: an enterprise/MDM-configured provider, // else matrix.org. The most recently used provider (from history) can override it, see init. - private val configuredAccountProvider = createAccountProvider( - url = enterpriseService.homeserverAllowList() - .firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } - ?: AuthenticationConfig.MATRIX_ORG_URL - ) + private val configuredAccountProvider = enterpriseService.accountProviderAllowList() + .firstOrNull() + ?: matrixOrgAccountProvider private val accountProvider: MutableStateFlow = MutableStateFlow(configuredAccountProvider) @@ -46,7 +44,7 @@ class AccountProviderDataSource( // The account provider the user last explicitly selected (via [setAccountProvider] / [setUrl]). // Unlike [flow], this is not recomputed by [reset], so it survives to be persisted to history on a // successful sign-in even when the login flow is torn down in between (e.g. across an OAuth round-trip). - var lastSelectedAccountProviderUrl: String? = null + var lastSelectedAccountProvider: AccountProvider? = null private set init { @@ -66,28 +64,19 @@ class AccountProviderDataSource( * connect to any provider, otherwise the enterprise/MDM-configured provider. */ private suspend fun defaultAccountProvider(): AccountProvider { - if (!enterpriseService.canConnectToAnyHomeserver()) { + if (!enterpriseService.canConnectToAnyAccountProvider()) { return configuredAccountProvider } val lastUsedProvider = appPreferencesStore.getHomeserverHistoryFlow().first().firstOrNull() - return lastUsedProvider?.let { createAccountProvider(it) } ?: configuredAccountProvider + return lastUsedProvider?.let { AccountProvider.Generic(it) } ?: configuredAccountProvider } suspend fun setUrl(url: String) { - setAccountProvider(createAccountProvider(url)) + setAccountProvider(AccountProvider.Generic(url)) } suspend fun setAccountProvider(data: AccountProvider) { - lastSelectedAccountProviderUrl = data.url + lastSelectedAccountProvider = data accountProvider.emit(data) } - - private fun createAccountProvider(url: String): AccountProvider { - return AccountProvider( - url = url, - subtitle = null, - isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, - isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, - ) - } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPreviewParam.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPreviewParam.kt index c14827d1a6..aca64b9db7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPreviewParam.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPreviewParam.kt @@ -9,27 +9,27 @@ package io.element.android.features.login.impl.accountprovider import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.appconfig.AuthenticationConfig +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider open class AccountProviderPreviewParam : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - anAccountProvider(), - anAccountProvider().copy(subtitle = null), - anAccountProvider().copy(subtitle = null, title = "invalid"), - anAccountProvider().copy(subtitle = null, title = "Other", isPublic = false, isMatrixOrg = false), - // Add other state here + anAccountProviderManaged(), ) } -fun anAccountProvider( - url: String = AuthenticationConfig.MATRIX_ORG_URL, - subtitle: String? = "Matrix.org is an open network for secure, decentralized communication.", - isPublic: Boolean = true, - isMatrixOrg: Boolean = true, -) = AccountProvider( - url = url, - subtitle = subtitle, - isPublic = isPublic, - isMatrixOrg = isMatrixOrg, +fun anAccountProviderManaged( + serverName: String = "matrix.org", + baseUrl: String = "https://matrix-client.matrix.org", + canUseServerName: Boolean = true, +) = AccountProvider.Managed( + serverName = serverName, + baseUrl = baseUrl, + canUseServerName = canUseServerName, +) + +fun anAccountProviderGeneric( + serverName: String = "matrix.org", +) = AccountProvider.Generic( + serverName = serverName, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt index 09b423f713..8365e9286c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt @@ -14,17 +14,14 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.login.impl.R import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize import io.element.android.libraries.designsystem.preview.ElementPreview @@ -32,6 +29,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider /** * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 @@ -60,37 +58,19 @@ fun AccountProviderView( .heightIn(min = 44.dp), verticalAlignment = Alignment.CenterVertically ) { - if (item.isMatrixOrg) { - RoundedIconAtom( - size = RoundedIconAtomSize.Medium, - resourceId = R.drawable.ic_matrix, - tint = Color.Unspecified, - ) - } else { - RoundedIconAtom( - size = RoundedIconAtomSize.Medium, - imageVector = CompoundIcons.Host(), - tint = ElementTheme.colors.iconPrimary, - ) - } + RoundedIconAtom( + size = RoundedIconAtomSize.Medium, + imageVector = CompoundIcons.Host(), + tint = ElementTheme.colors.iconPrimary, + ) Text( modifier = Modifier .padding(start = 16.dp) .weight(1f), - text = item.title, + text = item.friendlyServerName(), style = ElementTheme.typography.fontBodyLgMedium, color = ElementTheme.colors.textPrimary, ) - if (item.isPublic) { - Icon( - modifier = Modifier - .padding(start = 10.dp) - .size(16.dp), - imageVector = CompoundIcons.Public(), - contentDescription = null, - tint = ElementTheme.colors.iconSecondary, - ) - } if (selected) { Icon( modifier = Modifier @@ -101,15 +81,6 @@ fun AccountProviderView( ) } } - if (item.subtitle != null) { - Text( - modifier = Modifier - .padding(start = 46.dp, bottom = 12.dp, end = 26.dp), - text = item.subtitle, - style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textSecondary, - ) - } } } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt index b3b99115cc..14c1b0346b 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt @@ -25,8 +25,11 @@ class SaveAccountProviderToHistory( suspend operator fun invoke() { // Persist the provider the user actually selected, not the currently-exposed default: the latter can be // reset back to the history default while the login flow is torn down across an OAuth round-trip. - val url = accountProviderDataSource.lastSelectedAccountProviderUrl - ?: accountProviderDataSource.flow.value.url - appPreferencesStore.addHomeserverToHistory(url) + val accountProvider = accountProviderDataSource.lastSelectedAccountProvider + ?: accountProviderDataSource.flow.value + // Persist the sanitized name the user knows the provider by rather than its base url, which can be an + // internal client API url (e.g. matrix.org resolves to matrix-client.matrix.org) the user would not + // recognise, and so that the same provider is not stored twice with and without its scheme. + appPreferencesStore.addHomeserverToHistory(accountProvider.serverNameOrBaseUrl()) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvent.kt index f3fd02713f..d1b68190c6 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvent.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvent.kt @@ -8,7 +8,7 @@ package io.element.android.features.login.impl.changeserver -import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider sealed interface ChangeServerEvent { data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvent diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt index 9106d6e1c4..2e3a4c73b8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt @@ -14,13 +14,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl -import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails @@ -38,7 +38,7 @@ class ChangeServerPresenter( } val gateState = localNetworkPermissionGate.present( - urlOf = { provider -> provider.url }, + urlOf = { provider -> provider.serverNameOrBaseUrl() }, onProceed = { provider -> changeServer(provider, changeServerAction) }, ) @@ -63,11 +63,8 @@ class ChangeServerPresenter( changeServerAction: MutableState>, ) { suspend { - defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider( - title = data.title, - accountProviderUrl = data.url, - ) - val details = authenticationService.setHomeserver(data.url).getOrThrow() + defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(data) + val details = authenticationService.setHomeserver(data.serverNameOrBaseUrl()).getOrThrow() if (!details.isSupported) { throw ChangeServerError.UnsupportedServer } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderEvent.kt index bb50f123cb..d3fddeeb1f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderEvent.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderEvent.kt @@ -8,7 +8,7 @@ package io.element.android.features.login.impl.screens.chooseaccountprovider -import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider sealed interface ChooseAccountProviderEvent { data class SelectAccountProvider(val accountProvider: AccountProvider) : ChooseAccountProviderEvent diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt index e2863a295a..8984216ac8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt @@ -14,14 +14,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject -import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import kotlinx.collections.immutable.toImmutableList @Inject @@ -41,7 +39,7 @@ class ChooseAccountProviderPresenter( loginModeState.eventSink( LoginModeEvent.Submit( isAccountCreation = false, - homeserverUrl = provider.url, + homeserverUrl = provider.serverNameOrBaseUrl(), resolvedHomeserverUrl = null, loginHint = null, ) @@ -59,18 +57,8 @@ class ChooseAccountProviderPresenter( } val staticAccountProviderList = remember { - // The list cannot contains ANY_ACCOUNT_PROVIDER ("*") and cannot be empty at this point - enterpriseService.homeserverAllowList() - .map { it.ensureProtocol() } - .map { url -> - AccountProvider( - url = url, - subtitle = null, - isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, - isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, - ) - } - .toImmutableList() + // The list cannot be empty at this point + enterpriseService.accountProviderAllowList().toImmutableList() } return ChooseAccountProviderState( diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderState.kt index 398e32d687..b1e84fe8ec 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderState.kt @@ -8,9 +8,9 @@ package io.element.android.features.login.impl.screens.chooseaccountprovider -import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.login.LoginModeState import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import kotlinx.collections.immutable.ImmutableList data class ChooseAccountProviderState( diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStatePreviewParam.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStatePreviewParam.kt index ceb1cfd6ea..9dc99b466c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStatePreviewParam.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStatePreviewParam.kt @@ -9,31 +9,22 @@ package io.element.android.features.login.impl.screens.chooseaccountprovider import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.features.login.impl.accountprovider.anAccountProviderManaged import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import kotlinx.collections.immutable.toImmutableList open class ChooseAccountProviderStatePreviewParam : PreviewParameterProvider { - private val server1 = anAccountProvider( - url = "https://server1.io", - subtitle = null, - isPublic = false, - isMatrixOrg = false, + private val server1 = anAccountProviderManaged( + baseUrl = "https://server1.io", ) - private val server2 = anAccountProvider( - url = "https://server2.io", - subtitle = null, - isPublic = false, - isMatrixOrg = false, + private val server2 = anAccountProviderManaged( + baseUrl = "https://server2.io", ) - private val server3 = anAccountProvider( - url = "https://server3.io", - subtitle = null, - isPublic = false, - isMatrixOrg = false, + private val server3 = anAccountProviderManaged( + baseUrl = "https://server3.io", ) override val values: Sequence get() = sequenceOf( @@ -61,13 +52,12 @@ open class ChooseAccountProviderStatePreviewParam : PreviewParameterProvider = listOf( - anAccountProvider() + anAccountProviderManaged() ), selectedAccountProvider: AccountProvider? = null, loginModeState: LoginModeState = aLoginModeState(), diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt index 552165a7b2..c2d0bd05b2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt @@ -96,16 +96,8 @@ fun ChooseAccountProviderView( ) state.accountProviders.forEach { item -> - val alteredItem = if (item.isMatrixOrg) { - // Set the subtitle from the resource - item.copy( - subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle), - ) - } else { - item - } AccountProviderView( - item = alteredItem, + item = item, selected = item == state.selectedAccountProvider, onClick = { state.eventSink(ChooseAccountProviderEvent.SelectAccountProvider(item)) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index 627620ff31..201571a8ff 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -20,9 +20,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.changeserver.ChangeServerEvent import io.element.android.features.login.impl.changeserver.ChangeServerState @@ -30,8 +28,11 @@ import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeState import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider +import io.element.android.libraries.matrix.api.accountprovider.matrixOrgAccountProvider import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.coroutines.flow.map @AssistedInject class ConfirmAccountProviderPresenter( @@ -56,23 +57,25 @@ class ConfirmAccountProviderPresenter( val accountProvider by accountProviderDataSource.flow.collectAsState() val loginModeState = loginModePresenter.present() val changeServerState = changeServerPresenter.present() - val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList()) + val homeserverHistory by remember { + appPreferencesStore.getHomeserverHistoryFlow().map { history -> + history.map { AccountProvider.Generic(it) } + } + }.collectAsState(emptyList()) // The account providers offered for autocomplete: previously-used ones first, then the // enterprise/MDM-configured allow-list, then matrix.org (always available, even before any sign-in). - // The "*" wildcard is a routing marker, not a real provider, so it is filtered out. Everything is - // rendered without the https:// scheme (added back at connection time). + // Everything is rendered without the https:// scheme (added back at connection time). val autocompleteCandidates = remember(homeserverHistory) { - (homeserverHistory + enterpriseService.homeserverAllowList() + AuthenticationConfig.MATRIX_ORG_URL) - .filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } - .map { it.withoutScheme() } + (homeserverHistory + enterpriseService.accountProviderAllowList() + matrixOrgAccountProvider) + .map { it.friendlyServerName() } .distinct() } // Editable input, seeded from the current (history-defaulted) account provider until the user edits it. // Displayed without the scheme, so the field shows e.g. "matrix.org" rather than "https://matrix.org". var userInput by rememberSaveable { mutableStateOf(null) } - val accountProviderInput = userInput ?: accountProvider.url.withoutScheme() + val accountProviderInput = userInput ?: accountProvider.friendlyServerName() // Offer the first candidate that the current input is a (case-insensitive) prefix of. val accountProviderSuggestion = remember(accountProviderInput, autocompleteCandidates) { @@ -134,7 +137,7 @@ class ConfirmAccountProviderPresenter( val accountProviderUrl = host.ensureProtocol() submittedAccountProvider = accountProviderUrl changeServerState.eventSink( - ChangeServerEvent.ChangeServer(AccountProvider(url = accountProviderUrl)) + ChangeServerEvent.ChangeServer(AccountProvider.Generic(host)) ) } ConfirmAccountProviderEvent.ClearError -> { @@ -154,9 +157,3 @@ class ConfirmAccountProviderPresenter( ) } } - -/** - * The host part of an account provider URL, i.e. the value with any `https://` / `http://` scheme removed. - * Returns the input unchanged when it has no scheme (e.g. `matrix.org`). - */ -private fun String.withoutScheme(): String = substringAfter("://") diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt index db04e7b7ba..13d189af6f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt @@ -9,8 +9,8 @@ package io.element.android.features.login.impl.screens.loginpassword import android.os.Parcelable -import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.parcelize.Parcelize diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStatePreviewParam.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStatePreviewParam.kt index ba48e57b5d..bae4ed4df3 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStatePreviewParam.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStatePreviewParam.kt @@ -9,9 +9,9 @@ package io.element.android.features.login.impl.screens.loginpassword import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.features.login.impl.accountprovider.anAccountProviderManaged import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.core.SessionId open class LoginPasswordStatePreviewParam : PreviewParameterProvider { @@ -26,7 +26,7 @@ open class LoginPasswordStatePreviewParam : PreviewParameterProvider = AsyncData.Uninitialized, eventSink: (LoginPasswordEvent) -> Unit = {}, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt index 9a2dc3097b..c2a983a692 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -131,7 +131,7 @@ fun LoginPasswordView( iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), title = stringResource( id = R.string.screen_account_provider_signin_title, - state.accountProvider.title + state.accountProvider.friendlyServerName() ), subTitle = stringResource(id = R.string.screen_login_subtitle) ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingEvent.kt index 4b3993e2d1..6d5979946d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingEvent.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingEvent.kt @@ -8,9 +8,11 @@ package io.element.android.features.login.impl.screens.onboarding +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider + sealed interface OnBoardingEvent { data class OnSignIn( - val defaultAccountProvider: String + val defaultAccountProvider: AccountProvider ) : OnBoardingEvent data object OnVersionClick : OnBoardingEvent diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index 7e48ae22e8..84db4283b4 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -22,7 +22,6 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.appconfig.OnBoardingConfig import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.enterprise.api.canConnectToAnyHomeserver import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.login.LoginModeEvent @@ -31,9 +30,11 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.ui.utils.MultipleTapToUnlock import kotlinx.coroutines.launch +import timber.log.Timber @AssistedInject class OnBoardingPresenter( @@ -59,27 +60,35 @@ class OnBoardingPresenter( @Composable override fun present(): OnBoardingState { val localCoroutineScope = rememberCoroutineScope() - val forcedAccountProvider = remember { - // If homeserverAllowList() returns a singleton list, this is the default account provider. - // In this case, the user can sign in using this homeserver, or use QrCode login - enterpriseService.homeserverAllowList().singleOrNull() + val canConnectToAnyAccountProvider = remember { + enterpriseService.canConnectToAnyAccountProvider() } - val canConnectToAnyHomeserver = remember { - enterpriseService.canConnectToAnyHomeserver() + val forcedAccountProvider = remember { + // If accountProviderAllowList() returns a singleton list, and the user is not free to use another + // account provider, this is the default account provider. + // In this case, the user can sign in using this homeserver, or use QrCode login + if (canConnectToAnyAccountProvider) { + null + } else { + enterpriseService.accountProviderAllowList().singleOrNull() + } } val mustChooseAccountProvider = remember { - !canConnectToAnyHomeserver && enterpriseService.homeserverAllowList().size > 1 + !canConnectToAnyAccountProvider && enterpriseService.accountProviderAllowList().size > 1 } - val linkAccountProvider by produceState(initialValue = null) { + val linkAccountProvider by produceState(initialValue = null) { // Account provider from the link, if allowed by the enterprise service - value = params.accountProvider?.takeIf { - try { - defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(it, it) - true - } catch (_: Exception) { - false + value = params.accountProvider + ?.let { AccountProvider.Generic(it) } + ?.takeIf { + try { + defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(it) + true + } catch (e: Exception) { + Timber.e(e, "Failed to check account provider from link, assuming not allowed") + false + } } - } } val defaultAccountProvider = remember(linkAccountProvider) { // If there is a forced account provider, this is the default account provider @@ -105,11 +114,11 @@ class OnBoardingPresenter( when (event) { is OnBoardingEvent.OnSignIn -> localCoroutineScope.launch { // Ensure that the current account provider is set - accountProviderDataSource.setUrl(event.defaultAccountProvider) + accountProviderDataSource.setAccountProvider(event.defaultAccountProvider) loginModeState.eventSink( LoginModeEvent.Submit( isAccountCreation = false, - homeserverUrl = event.defaultAccountProvider, + homeserverUrl = event.defaultAccountProvider.serverNameOrBaseUrl(), resolvedHomeserverUrl = null, loginHint = params.loginHint?.takeIf { forcedAccountProvider == null }, ) @@ -134,7 +143,7 @@ class OnBoardingPresenter( defaultAccountProvider = defaultAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider, canLoginWithQrCode = canLoginWithQrCode, - canCreateAccount = defaultAccountProvider == null && canConnectToAnyHomeserver && OnBoardingConfig.CAN_CREATE_ACCOUNT, + canCreateAccount = defaultAccountProvider == null && canConnectToAnyAccountProvider && OnBoardingConfig.CAN_CREATE_ACCOUNT, canReportBug = canReportBug && showReportBug, loginModeState = loginModeState, version = buildMeta.versionName, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt index 97ebed67f6..87d4605ceb 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt @@ -11,13 +11,14 @@ package io.element.android.features.login.impl.screens.onboarding import androidx.annotation.DrawableRes import io.element.android.features.login.impl.login.LoginModeState import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider data class OnBoardingState( val isAddingAccount: Boolean, val showBackButton: Boolean, val showDeveloperSettings: Boolean, val productionApplicationName: String, - val defaultAccountProvider: String?, + val defaultAccountProvider: AccountProvider?, val mustChooseAccountProvider: Boolean, val canLoginWithQrCode: Boolean, val canCreateAccount: Boolean, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStatePreviewParam.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStatePreviewParam.kt index c7d89b21d5..09a2c8a245 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStatePreviewParam.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStatePreviewParam.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.libraries.designsystem.R +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider open class OnBoardingStatePreviewParam : PreviewParameterProvider { override val values: Sequence @@ -22,7 +23,11 @@ open class OnBoardingStatePreviewParam : PreviewParameterProvider - - - diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt index fbfae984fc..86bbd0eadb 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt @@ -11,19 +11,30 @@ package io.element.android.features.login.impl.accesscontrol import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.changeserver.AccountProviderAccessException +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL +import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderManaged import kotlinx.coroutines.test.runTest import org.junit.Assert.assertThrows import org.junit.Test class DefaultAccountProviderAccessControlTest { + /** + * The account provider the assertions are run against: its server name is the title reported by the exceptions, + * its base url is what the enterprise service is queried with. + */ + private fun anAccountProviderUnderTest() = anAccountProviderManaged( + serverName = AN_ACCOUNT_PROVIDER, + baseUrl = AN_ACCOUNT_PROVIDER_URL, + ) + @Test fun `foss build should not allow using account provider that enforce enterprise build`() { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, - isAllowedToConnectToHomeserver = true, + isAllowedToConnectToAccountProvider = true, enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }), ) accessControl.expectNeedElementProException() @@ -34,7 +45,7 @@ class DefaultAccountProviderAccessControlTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, // false here. - isAllowedToConnectToHomeserver = false, + isAllowedToConnectToAccountProvider = false, enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }), ) accessControl.expectNeedElementProException() @@ -44,9 +55,9 @@ class DefaultAccountProviderAccessControlTest { fun `foss build should allow using account provider that does not enforce enterprise build`() = runTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, - isAllowedToConnectToHomeserver = true, + isAllowedToConnectToAccountProvider = true, enterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = { true }, + isAllowedToConnectToAccountProviderResult = { true }, isElementProEnforcedResult = { false } ), ) @@ -57,7 +68,7 @@ class DefaultAccountProviderAccessControlTest { fun `foss build should allow using account provider twith missing key in wellknown`() = runTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, - isAllowedToConnectToHomeserver = true, + isAllowedToConnectToAccountProvider = true, ) accessControl.expectAllowed() } @@ -66,7 +77,7 @@ class DefaultAccountProviderAccessControlTest { fun `foss build should allow using account provider with missing wellknown`() = runTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, - isAllowedToConnectToHomeserver = true, + isAllowedToConnectToAccountProvider = true, ) accessControl.expectAllowed() } @@ -75,12 +86,12 @@ class DefaultAccountProviderAccessControlTest { fun `foss build should not allow using account provider that do not enforce enterprise build but is not allowed`() { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, - isAllowedToConnectToHomeserver = false, - allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), + isAllowedToConnectToAccountProvider = false, + allowedAccountProviders = listOf(anAccountProviderManaged(serverName = AN_ACCOUNT_PROVIDER_2)), enterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = { false }, + isAllowedToConnectToAccountProviderResult = { false }, isElementProEnforcedResult = { false }, - defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) }, + accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = AN_ACCOUNT_PROVIDER_2)) }, ), ) accessControl.expectUnauthorizedAccountProviderException() @@ -90,9 +101,9 @@ class DefaultAccountProviderAccessControlTest { fun `enterprise build should allow using account provider that enforce enterprise build`() = runTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = true, - isAllowedToConnectToHomeserver = true, + isAllowedToConnectToAccountProvider = true, enterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = { true }, + isAllowedToConnectToAccountProviderResult = { true }, isElementProEnforcedResult = { true }, ), ) @@ -103,9 +114,9 @@ class DefaultAccountProviderAccessControlTest { fun `enterprise build should allow using account provider that do not enforce enterprise build`() = runTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = true, - isAllowedToConnectToHomeserver = true, + isAllowedToConnectToAccountProvider = true, enterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = { true }, + isAllowedToConnectToAccountProviderResult = { true }, isElementProEnforcedResult = { false }, ), ) @@ -116,12 +127,12 @@ class DefaultAccountProviderAccessControlTest { fun `enterprise build should not allow using account provider that enforce enterprise build but is not allowed`() = runTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = true, - isAllowedToConnectToHomeserver = false, - allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), + isAllowedToConnectToAccountProvider = false, + allowedAccountProviders = listOf(anAccountProviderManaged(serverName = AN_ACCOUNT_PROVIDER_2)), enterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = { false }, + isAllowedToConnectToAccountProviderResult = { false }, isElementProEnforcedResult = { true }, - defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) }, + accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = AN_ACCOUNT_PROVIDER_2)) }, ), ) accessControl.expectUnauthorizedAccountProviderException() @@ -131,12 +142,12 @@ class DefaultAccountProviderAccessControlTest { fun `enterprise build should not allow using account provider that do not enforce enterprise build but is not allowed`() = runTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = true, - isAllowedToConnectToHomeserver = false, - allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), + isAllowedToConnectToAccountProvider = false, + allowedAccountProviders = listOf(anAccountProviderManaged(serverName = AN_ACCOUNT_PROVIDER_2)), enterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = { false }, + isAllowedToConnectToAccountProviderResult = { false }, isElementProEnforcedResult = { false }, - defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) }, + accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = AN_ACCOUNT_PROVIDER_2)) }, ), ) accessControl.expectUnauthorizedAccountProviderException() @@ -144,11 +155,11 @@ class DefaultAccountProviderAccessControlTest { private fun createDefaultAccountProviderAccessControl( isEnterpriseBuild: Boolean = false, - isAllowedToConnectToHomeserver: Boolean = false, - allowedAccountProviders: List = emptyList(), + isAllowedToConnectToAccountProvider: Boolean = false, + allowedAccountProviders: List = emptyList(), enterpriseService: FakeEnterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver }, - defaultHomeserverListResult = { allowedAccountProviders }, + isAllowedToConnectToAccountProviderResult = { isAllowedToConnectToAccountProvider }, + accountProviderAllowListResult = { allowedAccountProviders }, isElementProEnforcedResult = { false }, ) ) = DefaultAccountProviderAccessControl( @@ -159,19 +170,14 @@ class DefaultAccountProviderAccessControlTest { private fun DefaultAccountProviderAccessControl.expectNeedElementProException() { val exception = assertThrows(AccountProviderAccessException.NeedElementProException::class.java) { runTest { - assertIsAllowedToConnectToAccountProvider( - title = AN_ACCOUNT_PROVIDER, - accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, - ) + assertIsAllowedToConnectToAccountProvider(anAccountProviderUnderTest()) } } assertThat(exception.unauthorisedAccountProviderTitle).isEqualTo(AN_ACCOUNT_PROVIDER) assertThat(exception.applicationId).isEqualTo("io.element.enterprise") runTest { assertThat( - isAllowedToConnectToAccountProvider( - accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, - ) + isAllowedToConnectToAccountProvider(anAccountProviderUnderTest()) ).isFalse() } } @@ -179,34 +185,24 @@ class DefaultAccountProviderAccessControlTest { private fun DefaultAccountProviderAccessControl.expectUnauthorizedAccountProviderException() { val exception = assertThrows(AccountProviderAccessException.UnauthorizedAccountProviderException::class.java) { runTest { - assertIsAllowedToConnectToAccountProvider( - title = AN_ACCOUNT_PROVIDER, - accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, - ) + assertIsAllowedToConnectToAccountProvider(anAccountProviderUnderTest()) } } assertThat(exception.unauthorisedAccountProviderTitle).isEqualTo(AN_ACCOUNT_PROVIDER) assertThat(exception.authorisedAccountProviderTitles).containsExactly(AN_ACCOUNT_PROVIDER_2) runTest { assertThat( - isAllowedToConnectToAccountProvider( - accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, - ) + isAllowedToConnectToAccountProvider(anAccountProviderUnderTest()) ).isFalse() } } private suspend fun DefaultAccountProviderAccessControl.expectAllowed() { // If no exception is thrown, the test passes - assertIsAllowedToConnectToAccountProvider( - title = AN_ACCOUNT_PROVIDER, - accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, - ) + assertIsAllowedToConnectToAccountProvider(anAccountProviderUnderTest()) runTest { assertThat( - isAllowedToConnectToAccountProvider( - accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, - ) + isAllowedToConnectToAccountProvider(anAccountProviderUnderTest()) ).isTrue() } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt index 30e25c8c3e..ff03d79989 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt @@ -10,9 +10,10 @@ package io.element.android.features.login.impl.accountprovider import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider +import io.element.android.libraries.matrix.api.accountprovider.matrixOrgAccountProvider +import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderManaged import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest @@ -24,61 +25,24 @@ class AccountProviderDataSourceTest { val warmUpRule = WarmUpRule() @Test - fun `present - initial state`() = runTest { + fun `present - initial state - matrix org is the default when nothing is configured`() = runTest { val sut = anAccountProviderDataSource() sut.flow.test { - val initialState = awaitItem() - assertThat(initialState).isEqualTo( - AccountProvider( - url = AuthenticationConfig.MATRIX_ORG_URL, - title = "matrix.org", - subtitle = null, - isPublic = true, - isMatrixOrg = true, - ) - ) + assertThat(awaitItem()).isEqualTo(matrixOrgAccountProvider) } } @Test - fun `present - initial state - matrix org`() = runTest { + fun `present - initial state - the first configured account provider is the default`() = runTest { val sut = anAccountProviderDataSource( enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { listOf(AuthenticationConfig.MATRIX_ORG_URL) } + accountProviderAllowListResult = { + listOf(anAccountProviderManaged(serverName = "first.org"), anAccountProviderManaged(serverName = "second.org")) + } ), ) sut.flow.test { - val initialState = awaitItem() - assertThat(initialState).isEqualTo( - AccountProvider( - url = AuthenticationConfig.MATRIX_ORG_URL, - title = "matrix.org", - subtitle = null, - isPublic = true, - isMatrixOrg = true, - ) - ) - } - } - - @Test - fun `present - ensure that default homeserver is not star char`() = runTest { - val sut = anAccountProviderDataSource( - enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { listOf(EnterpriseService.ANY_ACCOUNT_PROVIDER, AuthenticationConfig.MATRIX_ORG_URL) } - ), - ) - sut.flow.test { - val initialState = awaitItem() - assertThat(initialState).isEqualTo( - AccountProvider( - url = AuthenticationConfig.MATRIX_ORG_URL, - title = "matrix.org", - subtitle = null, - isPublic = true, - isMatrixOrg = true, - ) - ) + assertThat(awaitItem()).isEqualTo(anAccountProviderManaged(serverName = "first.org")) } } @@ -86,22 +50,11 @@ class AccountProviderDataSourceTest { fun `present - user change and reset`() = runTest { val sut = anAccountProviderDataSource() sut.flow.test { - val initialState = awaitItem() - assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) - sut.setAccountProvider(AccountProvider(url = "https://example.com")) - val changedState = awaitItem() - assertThat(changedState).isEqualTo( - AccountProvider( - url = "https://example.com", - title = "example.com", - subtitle = null, - isPublic = false, - isMatrixOrg = false, - ) - ) + assertThat(awaitItem()).isEqualTo(matrixOrgAccountProvider) + sut.setAccountProvider(AccountProvider.Generic("https://example.com")) + assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("https://example.com")) sut.reset() - val resetState = awaitItem() - assertThat(resetState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + assertThat(awaitItem()).isEqualTo(matrixOrgAccountProvider) } } @@ -109,22 +62,23 @@ class AccountProviderDataSourceTest { fun `present - set url and reset`() = runTest { val sut = anAccountProviderDataSource() sut.flow.test { - val initialState = awaitItem() - assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + assertThat(awaitItem()).isEqualTo(matrixOrgAccountProvider) sut.setUrl(url = "https://example.com") - val changedState = awaitItem() - assertThat(changedState).isEqualTo( - AccountProvider( - url = "https://example.com", - title = "example.com", - subtitle = null, - isPublic = false, - isMatrixOrg = false, - ) + assertThat(awaitItem()).isEqualTo( + AccountProvider.Generic("https://example.com") ) sut.reset() - val resetState = awaitItem() - assertThat(resetState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + assertThat(awaitItem()).isEqualTo(matrixOrgAccountProvider) + } + } + + @Test + fun `present - a provider set by the user is stored as they input it`() = runTest { + val sut = anAccountProviderDataSource() + sut.flow.test { + skipItems(1) + sut.setUrl(url = "example.com") + assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("example.com")) } } @@ -136,7 +90,7 @@ class AccountProviderDataSourceTest { ), ) sut.flow.test { - assertThat(awaitItem().url).isEqualTo("https://example.com") + assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("https://example.com")) } } @@ -144,14 +98,31 @@ class AccountProviderDataSourceTest { fun `present - history is ignored when the account provider is enforced`() = runTest { val sut = anAccountProviderDataSource( enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { listOf("https://enforced.org") } + accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = "enforced.org")) }, + canConnectToAnyAccountProviderResult = { false }, ), appPreferencesStore = InMemoryAppPreferencesStore( homeserverHistory = listOf("https://example.com"), ), ) sut.flow.test { - assertThat(awaitItem().url).isEqualTo("https://enforced.org") + assertThat(awaitItem()).isEqualTo(anAccountProviderManaged(serverName = "enforced.org")) + } + } + + @Test + fun `present - history wins over the configured provider when other providers are allowed`() = runTest { + val sut = anAccountProviderDataSource( + enterpriseService = FakeEnterpriseService( + accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = "preferred.org")) }, + canConnectToAnyAccountProviderResult = { true }, + ), + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://example.com"), + ), + ) + sut.flow.test { + assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("https://example.com")) } } @@ -163,11 +134,11 @@ class AccountProviderDataSourceTest { ), ) sut.flow.test { - assertThat(awaitItem().url).isEqualTo("https://example.com") + assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("https://example.com")) sut.setUrl("https://other.com") - assertThat(awaitItem().url).isEqualTo("https://other.com") + assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("https://other.com")) sut.reset() - assertThat(awaitItem().url).isEqualTo("https://example.com") + assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("https://example.com")) } } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt index 0af8188f3f..604e01acfe 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt @@ -20,7 +20,7 @@ class SaveAccountProviderToHistoryTest { val warmUpRule = WarmUpRule() @Test - fun `invoke saves the current account provider url to history`() = runTest { + fun `invoke saves the current account provider server name to history`() = runTest { val appPreferencesStore = InMemoryAppPreferencesStore() val accountProviderDataSource = anAccountProviderDataSource() accountProviderDataSource.setUrl("https://example.com") @@ -29,7 +29,7 @@ class SaveAccountProviderToHistoryTest { sut() assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) - .containsExactly("https://example.com") + .containsExactly("example.com") } @Test @@ -45,6 +45,6 @@ class SaveAccountProviderToHistoryTest { sut() assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) - .containsExactly("https://example.com") + .containsExactly("example.com") } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt index 44a3bbd5cb..f43a575d37 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -12,14 +12,15 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl -import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderManaged import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog @@ -57,13 +58,13 @@ class ChangeServerPresenterTest { createPresenter( authenticationService = authenticationService, enterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = { true }, + isAllowedToConnectToAccountProviderResult = { true }, isElementProEnforcedResult = { false }, ), ).test { val initialState = awaitItem() assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) - initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) + initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider.Generic(A_HOMESERVER_URL))) val loadingState = awaitItem() assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) val successState = awaitItem() @@ -80,14 +81,14 @@ class ChangeServerPresenterTest { ) createPresenter( enterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = { true }, + isAllowedToConnectToAccountProviderResult = { true }, isElementProEnforcedResult = { false }, ), authenticationService = authenticationService, ).test { val initialState = awaitItem() assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) - initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) + initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider.Generic(A_HOMESERVER_URL))) val loadingState = awaitItem() assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) val failureState = awaitItem() @@ -108,14 +109,14 @@ class ChangeServerPresenterTest { ) createPresenter( enterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = { true }, + isAllowedToConnectToAccountProviderResult = { true }, isElementProEnforcedResult = { false }, ), authenticationService = authenticationService, ).test { val initialState = awaitItem() assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) - initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) + initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider.Generic(A_HOMESERVER_URL))) val loadingState = awaitItem() assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) val failureState = awaitItem() @@ -128,30 +129,30 @@ class ChangeServerPresenterTest { @Test fun `present - change server not allowed error`() = runTest { - val isAllowedToConnectToHomeserverResult = lambdaRecorder { false } + val isAllowedToConnectToAccountProviderResult = lambdaRecorder { false } createPresenter( enterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = isAllowedToConnectToHomeserverResult, - defaultHomeserverListResult = { listOf("element.io") }, + isAllowedToConnectToAccountProviderResult = isAllowedToConnectToAccountProviderResult, + accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = "element.io")) }, isElementProEnforcedResult = { false }, ), ).test { val initialState = awaitItem() assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) - val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL) + val anAccountProvider = AccountProvider.Generic(A_HOMESERVER_URL) initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(anAccountProvider)) val loadingState = awaitItem() assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) val failureState = awaitItem() assertThat( (failureState.changeServerAction.errorOrNull() as ChangeServerError.UnauthorizedAccountProvider).unauthorisedAccountProviderTitle - ).isEqualTo(anAccountProvider.title) + ).isEqualTo(anAccountProvider.serverNameOrBaseUrl()) assertThat( (failureState.changeServerAction.errorOrNull() as ChangeServerError.UnauthorizedAccountProvider).authorisedAccountProviderTitles ).containsExactly("element.io") - isAllowedToConnectToHomeserverResult.assertions() + isAllowedToConnectToAccountProviderResult.assertions() .isCalledOnce() - .with(value(A_HOMESERVER_URL)) + .with(value(AccountProvider.Generic(A_HOMESERVER_URL))) } } @@ -166,14 +167,14 @@ class ChangeServerPresenterTest { ).test { val initialState = awaitItem() assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) - val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL) + val anAccountProvider = AccountProvider.Generic(A_HOMESERVER_URL) initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(anAccountProvider)) // Skip loading state skipItems(1) val failureState = awaitItem() assertThat( (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle - ).isEqualTo(anAccountProvider.title) + ).isEqualTo(anAccountProvider.serverNameOrBaseUrl()) assertThat( (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId ).isEqualTo("io.element.enterprise") @@ -188,13 +189,13 @@ class ChangeServerPresenterTest { ) createPresenter( authenticationService = authenticationService, - enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }), + enterpriseService = FakeEnterpriseService(isAllowedToConnectToAccountProviderResult = { true }), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true), permissionsPresenter = FakePermissionsPresenter(), ).test { val initialState = awaitItem() assertThat(initialState.localNetworkPermissionDialog).isEqualTo(LocalNetworkPermissionDialog.None) - initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) + initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider.Generic(A_HOMESERVER_URL))) val promptState = expectMostRecentItem() // Dialog is shown, permission has not been requested yet, setHomeserver has not been called. assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None) @@ -208,12 +209,12 @@ class ChangeServerPresenterTest { authenticationService = FakeMatrixAuthenticationService( setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) }, ), - enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }), + enterpriseService = FakeEnterpriseService(isAllowedToConnectToAccountProviderResult = { true }), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true), permissionsPresenter = FakePermissionsPresenter(), ).test { val initialState = awaitItem() - initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) + initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider.Generic(A_HOMESERVER_URL))) val promptState = expectMostRecentItem() assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None) promptState.eventSink.invoke(ChangeServerEvent.DismissLocalNetworkPermission) @@ -231,12 +232,12 @@ class ChangeServerPresenterTest { val permissionsPresenter = FakePermissionsPresenter() createPresenter( authenticationService = authenticationService, - enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }, isElementProEnforcedResult = { false }), + enterpriseService = FakeEnterpriseService(isAllowedToConnectToAccountProviderResult = { true }, isElementProEnforcedResult = { false }), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true), permissionsPresenter = permissionsPresenter, ).test { val initialState = awaitItem() - initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) + initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider.Generic(A_HOMESERVER_URL))) val promptState = expectMostRecentItem() assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None) permissionsPresenter.setPermissionGranted() diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt index 78f4293489..17711cc469 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt @@ -11,14 +11,13 @@ package io.element.android.features.login.impl.screens.chooseaccountprovider import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService -import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.login.LoginModePresenter import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3 import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderManaged import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test @@ -33,18 +32,8 @@ class ChooseAccountProviderPresenterTest { companion object { private const val ACCOUNT_PROVIDER_FROM_CONFIG_1 = AN_ACCOUNT_PROVIDER_2 private const val ACCOUNT_PROVIDER_FROM_CONFIG_2 = AN_ACCOUNT_PROVIDER_3 - val accountProvider1 = AccountProvider( - url = ACCOUNT_PROVIDER_FROM_CONFIG_1.ensureProtocol(), - subtitle = null, - isPublic = false, - isMatrixOrg = false, - ) - val accountProvider2 = AccountProvider( - url = ACCOUNT_PROVIDER_FROM_CONFIG_2.ensureProtocol(), - subtitle = null, - isPublic = false, - isMatrixOrg = false, - ) + val accountProvider1 = anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG_1) + val accountProvider2 = anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG_2) } @Test @@ -61,7 +50,7 @@ class ChooseAccountProviderPresenterTest { fun `present - initial state`() = runTest { val presenter = createPresenter( enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, + accountProviderAllowListResult = { listOf(accountProvider1, accountProvider2) }, ), ) presenter.test { @@ -79,7 +68,7 @@ class ChooseAccountProviderPresenterTest { val authenticationService = FakeMatrixAuthenticationService() val presenter = createPresenter( enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, + accountProviderAllowListResult = { listOf(accountProvider1, accountProvider2) }, ), loginModePresenter = createLoginModePresenter( authenticationService = authenticationService, @@ -103,7 +92,7 @@ class ChooseAccountProviderPresenterTest { ) val presenter = createPresenter( enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, + accountProviderAllowListResult = { listOf(accountProvider1, accountProvider2) }, ), loginModePresenter = createLoginModePresenter( authenticationService = authenticationService, @@ -136,7 +125,7 @@ class ChooseAccountProviderPresenterTest { val authenticationService = FakeMatrixAuthenticationService() val presenter = createPresenter( enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, + accountProviderAllowListResult = { listOf(accountProvider1, accountProvider2) }, ), loginModePresenter = createLoginModePresenter( authenticationService = authenticationService, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateTest.kt index a5cb54e7e1..c4af8f9544 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateTest.kt @@ -9,7 +9,7 @@ package io.element.android.features.login.impl.screens.chooseaccountprovider import com.google.common.truth.Truth.assertThat -import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.features.login.impl.accountprovider.anAccountProviderManaged import io.element.android.features.login.impl.login.LoginMode import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.libraries.architecture.AsyncData @@ -27,7 +27,7 @@ class ChooseAccountProviderStateTest { @Test fun `submitEnabled returns true when there is a selectedAccountProvider`() { val sut = aChooseAccountProviderState( - selectedAccountProvider = anAccountProvider(), + selectedAccountProvider = anAccountProviderManaged(), ) assertThat(sut.submitEnabled).isTrue() } @@ -35,7 +35,7 @@ class ChooseAccountProviderStateTest { @Test fun `submitEnabled returns false when there is a selectedAccountProvider but there is an error`() { val sut = aChooseAccountProviderState( - selectedAccountProvider = anAccountProvider(), + selectedAccountProvider = anAccountProviderManaged(), loginModeState = aLoginModeState(loginMode = AsyncData.Failure(Throwable("Error"))), ) assertThat(sut.submitEnabled).isFalse() @@ -44,7 +44,7 @@ class ChooseAccountProviderStateTest { @Test fun `submitEnabled returns false when there is a selectedAccountProvider but the result is successful`() { val sut = aChooseAccountProviderState( - selectedAccountProvider = anAccountProvider(), + selectedAccountProvider = anAccountProviderManaged(), loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.PasswordLogin)), ) assertThat(sut.submitEnabled).isFalse() diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt index 4ef7a71966..9bb8da717e 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.v2.runAndroidComposeUiTest -import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.features.login.impl.accountprovider.anAccountProviderManaged import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.auth.OAuthDetails @@ -57,11 +57,11 @@ class ChooseAccountProviderViewTest : RobolectricTest() { ChooseAccountProviderPresenterTest.accountProvider1, ChooseAccountProviderPresenterTest.accountProvider2, ), - selectedAccountProvider = anAccountProvider(), + selectedAccountProvider = anAccountProviderManaged(), eventSink = eventSink, ), ) - onNodeWithText(ChooseAccountProviderPresenterTest.accountProvider1.title).performClick() + onNodeWithText(ChooseAccountProviderPresenterTest.accountProvider1.friendlyServerName()).performClick() eventSink.assertSingle(ChooseAccountProviderEvent.SelectAccountProvider(ChooseAccountProviderPresenterTest.accountProvider1)) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 19afbe0f28..9bccb2c261 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -10,7 +10,6 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat -import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl @@ -24,6 +23,7 @@ import io.element.android.features.login.impl.screens.onboarding.createLoginMode import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderManaged import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails import io.element.android.libraries.oauth.api.OAuthAction @@ -44,6 +44,11 @@ class ConfirmAccountProviderPresenterTest { @get:Rule val warmUpRule = WarmUpRule() + companion object { + private const val MATRIX_ORG_SERVER_NAME = "matrix.org" + private const val MATRIX_ORG_URL = "https://matrix.org" + } + @Test fun `present - initial test`() = runTest { val presenter = createConfirmAccountProviderPresenter() @@ -68,7 +73,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL)) val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin) cancelAndIgnoreRemainingEvents() @@ -87,7 +92,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL)) val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) cancelAndIgnoreRemainingEvents() @@ -108,7 +113,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL)) val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) authenticationService.givenOAuthCancelError(AN_EXCEPTION) @@ -133,7 +138,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL)) val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) defaultOAuthActionFlow.post(OAuthAction.GoBack()) @@ -157,7 +162,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL)) awaitLoginMode { it is AsyncData.Loading } defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true)) val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized } @@ -180,7 +185,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL)) val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) authenticationService.givenLoginError(AN_EXCEPTION) @@ -205,7 +210,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL)) val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) defaultOidcActionFlow.post(OAuthAction.Success("aUrl")) @@ -227,7 +232,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL)) // The account provider validation fails, so the login is never attempted. val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) @@ -249,7 +254,7 @@ class ConfirmAccountProviderPresenterTest { val initialState = awaitItem() // Submit will return an error while validating the account provider - initialState.eventSink(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + initialState.eventSink(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL)) // Check an error was returned val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } @@ -274,7 +279,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + initialState.eventSink(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL)) // Check an error was returned val submittedState = awaitLoginMode { it is AsyncData.Failure } assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java) @@ -299,7 +304,7 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + initialState.eventSink(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL)) val submittedState = awaitLoginMode { it is AsyncData.Success } assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) cancelAndIgnoreRemainingEvents() @@ -344,7 +349,7 @@ class ConfirmAccountProviderPresenterTest { val suggestionState = awaitState { it.accountProviderSuggestion != null } suggestionState.eventSink(ConfirmAccountProviderEvent.Continue("randomcommunity.org")) awaitLoginMode { it is AsyncData.Success } - assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org") + assertThat(submittedUrls.first()).isEqualTo("randomcommunity.org") cancelAndIgnoreRemainingEvents() } } @@ -375,8 +380,7 @@ class ConfirmAccountProviderPresenterTest { val presenter = createConfirmAccountProviderPresenter( appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), enterpriseService = FakeEnterpriseService( - // Preferred servers plus the "*" (any) wildcard: the wildcard is filtered out, the rest complete. - defaultHomeserverListResult = { listOf("https://element.io", EnterpriseService.ANY_ACCOUNT_PROVIDER) }, + accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = "element.io")) }, ), ) presenter.test { @@ -387,6 +391,19 @@ class ConfirmAccountProviderPresenterTest { } } + @Test + fun `present - offers the previously used servers without the scheme as autocomplete suggestions`() = runTest { + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = listOf("https://previous.org")), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvent.UserInputChanged("prev")) + val suggestionState = awaitState { it.accountProviderInput == "prev" } + assertThat(suggestionState.accountProviderSuggestion).isEqualTo("previous.org") + } + } + @Test fun `present - offers matrix_org without the scheme as an autocomplete suggestion`() = runTest { val presenter = createConfirmAccountProviderPresenter( @@ -432,7 +449,7 @@ class ConfirmAccountProviderPresenterTest { val initialState = awaitItem() initialState.eventSink(ConfirmAccountProviderEvent.Continue("matrix.org")) awaitLoginMode { it is AsyncData.Success } - assertThat(submittedUrls.first()).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + assertThat(submittedUrls.first()).isEqualTo(MATRIX_ORG_SERVER_NAME) cancelAndIgnoreRemainingEvents() } } @@ -453,9 +470,9 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + initialState.eventSink(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL)) awaitLoginMode { it is AsyncData.Success } - assertThat(submittedUrls).containsExactly(AuthenticationConfig.MATRIX_ORG_URL) + assertThat(submittedUrls).containsExactly(MATRIX_ORG_SERVER_NAME) cancelAndIgnoreRemainingEvents() } } @@ -474,7 +491,7 @@ class ConfirmAccountProviderPresenterTest { val initialState = awaitItem() initialState.eventSink(ConfirmAccountProviderEvent.Continue("@alice:example.org")) awaitLoginMode { it is AsyncData.Success } - assertThat(submittedUrls.first()).isEqualTo("https://example.org") + assertThat(submittedUrls.first()).isEqualTo("example.org") assertThat(authenticationService.getOAuthUrlLoginHint).isEqualTo("mxid:@alice:example.org") cancelAndIgnoreRemainingEvents() } @@ -494,7 +511,7 @@ class ConfirmAccountProviderPresenterTest { val initialState = awaitItem() initialState.eventSink(ConfirmAccountProviderEvent.Continue("@alice:example.org:8448")) awaitLoginMode { it is AsyncData.Success } - assertThat(submittedUrls.first()).isEqualTo("https://example.org:8448") + assertThat(submittedUrls.first()).isEqualTo("example.org:8448") cancelAndIgnoreRemainingEvents() } } @@ -570,7 +587,7 @@ class ConfirmAccountProviderPresenterTest { accountProviderDataSource = accountProviderDataSource, defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( enterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = { true }, + isAllowedToConnectToAccountProviderResult = { true }, isElementProEnforcedResult = { false }, ), isEnterpriseBuild = { false }, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt index b12c9ec667..015ece9e23 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt @@ -9,11 +9,11 @@ package io.element.android.features.login.impl.screens.loginpassword import com.google.common.truth.Truth.assertThat -import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.accountprovider.matrixOrgAccountProvider import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_PASSWORD @@ -39,7 +39,7 @@ class LoginPasswordPresenterTest { fun `present - initial state`() = runTest { createLoginPasswordPresenter().test { val initialState = awaitItem() - assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + assertThat(initialState.accountProvider).isEqualTo(matrixOrgAccountProvider) assertThat(initialState.formState).isEqualTo(LoginFormState.Default) assertThat(initialState.loginAction).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.submitEnabled).isFalse() @@ -127,7 +127,7 @@ class LoginPasswordPresenterTest { val loggedInState = awaitItem() assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Success(A_SESSION_ID)) assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) - .containsExactly(AuthenticationConfig.MATRIX_ORG_URL) + .containsExactly(matrixOrgAccountProvider.serverName) } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index 1088a87538..4fecc3ada8 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -9,7 +9,6 @@ package io.element.android.features.login.impl.screens.onboarding import com.google.common.truth.Truth.assertThat -import io.element.android.appconfig.AuthenticationConfig import io.element.android.appconfig.OnBoardingConfig import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.IsEnterpriseBuild @@ -22,6 +21,8 @@ import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissio import io.element.android.features.login.impl.login.LoginModePresenter import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider +import io.element.android.libraries.matrix.api.accountprovider.matrixOrgAccountProvider import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 @@ -30,6 +31,7 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2 import io.element.android.libraries.matrix.test.A_LOGIN_HINT +import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderManaged import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.oauth.api.OAuthActionFlow @@ -82,7 +84,8 @@ class OnBoardingPresenterTest { val presenter = createPresenter( buildMeta = buildMeta, enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) }, + accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG)) }, + canConnectToAnyAccountProviderResult = { true }, ), ) presenter.test { @@ -184,15 +187,16 @@ class OnBoardingPresenterTest { showBackButton = false, ), enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) }, - isAllowedToConnectToHomeserverResult = { true }, + accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG)) }, + canConnectToAnyAccountProviderResult = { true }, + isAllowedToConnectToAccountProviderResult = { true }, isElementProEnforcedResult = { false }, ), ) presenter.test { skipItems(3) awaitItem().also { - assertThat(it.defaultAccountProvider).isEqualTo(ACCOUNT_PROVIDER_FROM_LINK) + assertThat(it.defaultAccountProvider).isEqualTo(AccountProvider.Generic(ACCOUNT_PROVIDER_FROM_LINK)) assertThat(it.canLoginWithQrCode).isFalse() assertThat(it.canCreateAccount).isFalse() } @@ -208,8 +212,14 @@ class OnBoardingPresenterTest { showBackButton = false, ), enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, - isAllowedToConnectToHomeserverResult = { false }, + accountProviderAllowListResult = { + listOf( + anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG), + anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG_2) + ) + }, + canConnectToAnyAccountProviderResult = { false }, + isAllowedToConnectToAccountProviderResult = { false }, ), ) presenter.test { @@ -231,19 +241,60 @@ class OnBoardingPresenterTest { showBackButton = false, ), enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG) }, + accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG)) }, + canConnectToAnyAccountProviderResult = { false }, ) ) presenter.test { skipItems(1) awaitItem().also { - assertThat(it.defaultAccountProvider).isEqualTo(ACCOUNT_PROVIDER_FROM_CONFIG) + assertThat(it.defaultAccountProvider).isEqualTo(anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG)) assertThat(it.canLoginWithQrCode).isTrue() assertThat(it.canCreateAccount).isFalse() } } } + @Test + fun `present - a single configured account provider is not forced when the user may use another one`() = runTest { + // A singleton allow list only forces the account provider when the user is not free to use another one. + val presenter = createPresenter( + enterpriseService = FakeEnterpriseService( + accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG)) }, + canConnectToAnyAccountProviderResult = { true }, + ), + ) + presenter.test { + awaitItem().also { + assertThat(it.defaultAccountProvider).isNull() + assertThat(it.mustChooseAccountProvider).isFalse() + } + skipItems(1) + } + } + + @Test + fun `present - the user must choose when several account providers are configured and enforced`() = runTest { + val presenter = createPresenter( + enterpriseService = FakeEnterpriseService( + accountProviderAllowListResult = { + listOf( + anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG), + anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG_2) + ) + }, + canConnectToAnyAccountProviderResult = { false }, + ), + ) + presenter.test { + awaitItem().also { + assertThat(it.defaultAccountProvider).isNull() + assertThat(it.mustChooseAccountProvider).isTrue() + } + skipItems(1) + } + } + @Test fun `present - default account provider - login and clear error`() = runTest { val authenticationService = FakeMatrixAuthenticationService( @@ -259,7 +310,7 @@ class OnBoardingPresenterTest { showBackButton = false, ), enterpriseService = FakeEnterpriseService( - isAllowedToConnectToHomeserverResult = { true }, + isAllowedToConnectToAccountProviderResult = { true }, isElementProEnforcedResult = { false }, ), loginModePresenter = createLoginModePresenter( @@ -270,12 +321,12 @@ class OnBoardingPresenterTest { presenter.test { skipItems(3) awaitItem().also { - assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL) - assertThat(accountProviderDataSource.flow.first().url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) - it.eventSink(OnBoardingEvent.OnSignIn(A_HOMESERVER_URL_2)) + assertThat(it.defaultAccountProvider).isEqualTo(AccountProvider.Generic(A_HOMESERVER_URL)) + assertThat(accountProviderDataSource.flow.first()).isEqualTo(matrixOrgAccountProvider) + it.eventSink(OnBoardingEvent.OnSignIn(AccountProvider.Generic(A_HOMESERVER_URL_2))) skipItems(1) // Loading // Account data source has been updated - assertThat(accountProviderDataSource.flow.first().url).isEqualTo(A_HOMESERVER_URL_2) + assertThat(accountProviderDataSource.flow.first()).isEqualTo(AccountProvider.Generic(A_HOMESERVER_URL_2)) // Check an error was returned val submittedState = awaitItem() assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt index 88c9acf55f..c7cc2b9f03 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt @@ -23,6 +23,7 @@ import io.element.android.features.login.impl.R import io.element.android.features.login.impl.login.LoginMode import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.auth.OAuthDetails import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.ui.strings.CommonStrings @@ -134,13 +135,13 @@ class OnboardingViewTest : RobolectricTestParameter() { val eventSink = EventsRecorder() setOnboardingView( state = anOnBoardingState( - defaultAccountProvider = "element.io", + defaultAccountProvider = AccountProvider.Generic("element.io"), eventSink = eventSink, ), ) val buttonText = activity!!.getString(R.string.screen_onboarding_sign_in_to, "element.io") onNodeWithText(buttonText).performClick() - eventSink.assertSingle(OnBoardingEvent.OnSignIn("element.io")) + eventSink.assertSingle(OnBoardingEvent.OnSignIn(AccountProvider.Generic("element.io"))) } @Test @@ -148,7 +149,7 @@ class OnboardingViewTest : RobolectricTestParameter() { val eventSink = EventsRecorder() setOnboardingView( state = anOnBoardingState( - defaultAccountProvider = "element.io", + defaultAccountProvider = AccountProvider.Generic("element.io"), loginModeState = aLoginModeState(loginMode = AsyncData.Failure(AN_EXCEPTION)), eventSink = eventSink, ), diff --git a/features/login/test/build.gradle.kts b/features/login/test/build.gradle.kts index 4cbf0910db..41c231a594 100644 --- a/features/login/test/build.gradle.kts +++ b/features/login/test/build.gradle.kts @@ -16,6 +16,7 @@ android { dependencies { implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) implementation(projects.features.login.api) implementation(projects.tests.testutils) } diff --git a/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt b/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt index 9ebd3be7fc..2864669e7f 100644 --- a/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt +++ b/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt @@ -8,12 +8,13 @@ package io.element.android.features.login.test.accesscontrol import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl +import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.tests.testutils.lambda.lambdaError class FakeAccountProviderAccessControl( - private val isAllowedToConnectToAccountProviderResult: (String) -> Boolean = { lambdaError() }, + private val isAllowedToConnectToAccountProviderResult: (AccountProvider) -> Boolean = { lambdaError() }, ) : AccountProviderAccessControl { - override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean { - return isAllowedToConnectToAccountProviderResult(accountProviderUrl) + override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean { + return isAllowedToConnectToAccountProviderResult(accountProvider) } } diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt index 2cf698dd84..94365ca01d 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStatePreviewParam.kt @@ -39,5 +39,6 @@ fun aVoiceMessagePreviewState() = VoiceMessageState.Preview( showCursor = false, playbackProgress = 0f, time = 10.seconds, + duration = 10.seconds, waveform = WaveFormSamples.realisticWaveForm, ) diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 0b51a2b80d..58c71a96f6 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { implementation(projects.libraries.voicerecorder.api) implementation(projects.libraries.mediaplayer.api) implementation(projects.libraries.push.api) + implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiUtils) implementation(projects.libraries.testtags) implementation(projects.libraries.wellknown.api) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt index 4d621e417f..38aa5b75ce 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt @@ -18,6 +18,8 @@ sealed interface MessagesEvent { data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent data class OnUserClicked(val user: MatrixUser) : MessagesEvent + data class ConfirmRedact(val reason: String?) : MessagesEvent + data object CancelRedact : MessagesEvent data object StopLiveLocationShare : MessagesEvent data object ShowLiveLocationShare : MessagesEvent data object MarkAsFullyReadAndExit : MessagesEvent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 97d3465ce7..e0213f2fb5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -51,6 +51,7 @@ import io.element.android.libraries.androidutils.system.toast import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.annotations.ApplicationContext @@ -103,6 +104,7 @@ class MessagesNode( private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val eventContentValidationCache: EventContentValidationCache, private val emojiPickerRenderer: EmojiPickerRenderer, + private val dispatchers: CoroutineDispatchers, ) : Node(buildContext, plugins = plugins), MessagesNavigator { data class Inputs( val focusedEventId: EventId?, @@ -111,7 +113,12 @@ class MessagesNode( private val inputs = inputs() private val callback: Callback = callback() - private val timelineController = TimelineController(room, room.liveTimeline) + private val timelineController = TimelineController( + room = room, + liveTimeline = room.liveTimeline, + roomCoroutineScope = room.roomCoroutineScope, + dispatchers = dispatchers, + ) private val presenter = presenterFactory.create( navigator = this, composerPresenter = messageComposerPresenterFactory.create(timelineController, this, threadRoot = null), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 6a019d865b..812ec704f0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -58,6 +58,7 @@ import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -84,6 +85,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.model.dmUserStatus import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -241,6 +243,8 @@ class MessagesPresenter( onPauseOrDispose {} } + val redactEventAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + fun handleEvent(event: MessagesEvent) { when (event) { is MessagesEvent.HandleAction -> { @@ -251,8 +255,21 @@ class MessagesPresenter( enableTextFormatting = composerState.showTextFormatting, timelineState = timelineState, timelineProtectionState = timelineProtectionState, + redactEventAction = redactEventAction, ) } + is MessagesEvent.ConfirmRedact -> { + val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction + redactEventAction.value = AsyncAction.Uninitialized + if (confirming != null) { + localCoroutineScope.launch { + redact(confirming.eventId.toEventOrTransactionId(), event.reason?.takeIf { it.isNotBlank() }) + } + } + } + MessagesEvent.CancelRedact -> { + redactEventAction.value = AsyncAction.Uninitialized + } is MessagesEvent.ToggleReaction -> { localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId) } @@ -331,6 +348,7 @@ class MessagesPresenter( hasUnreadThreads = false, ), showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread, + redactEventAction = redactEventAction.value, eventSink = ::handleEvent, ) } @@ -369,12 +387,13 @@ class MessagesPresenter( timelineProtectionState: TimelineProtectionState, enableTextFormatting: Boolean, timelineState: TimelineState, + redactEventAction: MutableState>, ) = launch { when (action) { TimelineItemAction.CopyText -> handleCopyContents(targetEvent) TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent) TimelineItemAction.CopyLink -> handleCopyLink(targetEvent) - TimelineItemAction.Redact -> handleActionRedact(targetEvent) + TimelineItemAction.Redact -> handleActionRedact(targetEvent, redactEventAction) TimelineItemAction.Edit, TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting) TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState) @@ -496,9 +515,19 @@ class MessagesPresenter( ) } - private suspend fun handleActionRedact(event: TimelineItem.Event) { + private suspend fun handleActionRedact(event: TimelineItem.Event, redactEventAction: MutableState>) { + val eventId = event.eventId + if (eventId == null) { + // The message was never sent, so there is nobody to give a reason to. + redact(event.eventOrTransactionId, reason = null) + } else { + redactEventAction.value = MessagesState.ConfirmingRedaction(eventId) + } + } + + private suspend fun redact(eventOrTransactionId: EventOrTransactionId, reason: String?) { timelineController.invokeOnCurrentTimeline { - redactEvent(eventOrTransactionId = event.eventOrTransactionId, reason = null) + redactEvent(eventOrTransactionId = eventOrTransactionId, reason = reason) .onFailure { Timber.e(it) } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index d9ec875320..2073b7f847 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -21,9 +21,11 @@ import io.element.android.features.messages.impl.timeline.components.receipt.bot import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom @@ -61,6 +63,7 @@ data class MessagesState( val successorRoom: SuccessorRoom?, val threads: Threads, val showLiveLocationShareBanner: Boolean, + val redactEventAction: AsyncAction, val eventSink: (MessagesEvent) -> Unit ) { val isTombstoned = successorRoom != null @@ -69,6 +72,11 @@ data class MessagesState( val hasThreads: Boolean, val hasUnreadThreads: Boolean, ) + + /** The user asked to remove [eventId] and has to confirm, optionally giving a reason. */ + data class ConfirmingRedaction( + val eventId: EventId, + ) : AsyncAction.Confirming } /** Type of "shared history" icon to show in the top bar. */ diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt index d792ab5ed4..53f90ba7d8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStatePreviewParam.kt @@ -41,10 +41,12 @@ import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ROOM_NAME +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.encryption.identity.IdentityState @@ -92,6 +94,7 @@ open class MessagesStatePreviewParam : PreviewParameterProvider { composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()), identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())) ), + aMessagesState(redactEventAction = MessagesState.ConfirmingRedaction(EventId("\$anEventId"))), ) } @@ -131,6 +134,7 @@ fun aMessagesState( isCurrentlySharingLiveLocationInRoom: Boolean = false, dmUserStatus: DisplayedStatus? = null, eventSink: (MessagesEvent) -> Unit = {}, + redactEventAction: AsyncAction = AsyncAction.Uninitialized, ) = MessagesState( roomId = RoomId("!id:domain"), roomName = roomName, @@ -161,6 +165,7 @@ fun aMessagesState( threads = threads, showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom, dmUserStatus = dmUserStatus, + redactEventAction = redactEventAction, eventSink = eventSink, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 16ca023d50..af8197c8c0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -104,6 +104,7 @@ import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertM import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayoutState import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog import io.element.android.libraries.designsystem.components.rememberExpandableBottomSheetLayoutState import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -455,6 +456,20 @@ fun MessagesView( state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog) }, ) + + if (state.redactEventAction is MessagesState.ConfirmingRedaction) { + TextFieldDialog( + title = stringResource(R.string.screen_room_confirm_removal_title), + content = stringResource(R.string.screen_room_confirm_removal_message), + placeholder = stringResource(R.string.screen_room_confirm_removal_reason_placeholder_android), + supportingText = stringResource(R.string.screen_room_confirm_removal_reason_supporting_text), + value = null, + submitText = stringResource(CommonStrings.action_remove), + destructiveSubmit = true, + onSubmit = { reason -> state.eventSink(MessagesEvent.ConfirmRedact(reason)) }, + onDismissRequest = { state.eventSink(MessagesEvent.CancelRedact) }, + ) + } } @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 5daec3e275..298a783e97 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.components.TimelineIte import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -58,6 +57,7 @@ import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.compose.LocalAnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction 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 cf3c701015..b5542ec4be 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 @@ -54,6 +54,7 @@ import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.di.RoomScope import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer @@ -95,6 +96,7 @@ class ThreadedMessagesNode( private val appNavigationStateService: AppNavigationStateService, private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val emojiPickerRenderer: EmojiPickerRenderer, + private val dispatchers: CoroutineDispatchers, ) : Node(buildContext, plugins = plugins), MessagesNavigator { data class Inputs( val threadRootEventId: ThreadId, @@ -113,7 +115,12 @@ class ThreadedMessagesNode( */ private suspend fun createPresenter(): Presenter { val threadedTimeline = room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow() - val timelineController = TimelineController(room, threadedTimeline) + val timelineController = TimelineController( + room, + liveTimeline = threadedTimeline, + roomCoroutineScope = room.roomCoroutineScope, + dispatchers = dispatchers, + ) this.timelineController = timelineController return presenterFactory.create( navigator = this, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt index e41ac7bee7..05f7a06054 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt @@ -12,7 +12,10 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.binding import io.element.android.features.messages.impl.timeline.di.LiveTimeline +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.RoomCoroutineScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.room.CreateTimelineParams @@ -23,7 +26,6 @@ import io.element.android.libraries.matrix.api.timeline.TimelineProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -47,8 +49,10 @@ import java.util.Optional class TimelineController( private val room: JoinedRoom, @LiveTimeline private val liveTimeline: Timeline, + @RoomCoroutineScope private val roomCoroutineScope: CoroutineScope, + dispatchers: CoroutineDispatchers, ) : Closeable, TimelineProvider { - private val coroutineScope = CoroutineScope(SupervisorJob()) + private val coroutineScope = roomCoroutineScope.childScope(dispatchers.computation, "TimelineController") private val liveTimelineFlow = flowOf(liveTimeline) private val detachedTimelineFlow = MutableStateFlow>(Optional.empty()) 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 99e72f4973..97ec71173a 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 @@ -72,8 +72,6 @@ import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -139,6 +137,8 @@ import io.element.android.libraries.matrix.ui.messages.sender.SenderName import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index 5b620a7dc3..0128e80b79 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -23,7 +23,6 @@ import io.element.android.features.messages.impl.timeline.aRedactedMessagesGroup import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup @@ -35,6 +34,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.wysiwyg.link.Link @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index fb9659ff70..aa66c262b5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -29,7 +29,6 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent @@ -46,6 +45,7 @@ import io.element.android.libraries.designsystem.text.toPx import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.wysiwyg.link.Link diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt index 38d575718f..586ff6cfe5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt @@ -22,12 +22,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData /** * package-private, you should only use TimelineItemFileView and TimelineItemAudioView. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt index d47ca552b8..a6c6afcbc9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt @@ -12,11 +12,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentPreviewParam import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData @Composable fun TimelineItemAudioView( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt index 6b9022d3cf..38920af287 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContentPreviewParam import io.element.android.libraries.designsystem.icons.CompoundDrawables @@ -20,6 +19,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 4b7ab9372d..839df1d320 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -24,8 +24,6 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.TimelineEvent -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.rememberPresenter import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent @@ -60,6 +58,8 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundCo import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.link.Link diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt index 77df9ba923..bc3fe91eca 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -13,11 +13,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentPreviewParam import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings /** diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt index 41a950ee5e..34f22d9d48 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt @@ -22,12 +22,12 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.libraries.designsystem.icons.CompoundDrawables import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData @Composable fun TimelineItemInformativeView( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt index e32de7f4ae..70ac84eb31 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt @@ -11,11 +11,11 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.libraries.designsystem.icons.CompoundDrawables import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index 86b50f3015..bb8e30aca3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -23,8 +23,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.theme.ElementTheme -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentPreviewParam @@ -36,6 +34,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.utils.LocalUiTestMode import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.link.Link diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt index fbbefca207..8f375ddb5b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt @@ -11,11 +11,11 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.libraries.designsystem.icons.CompoundDrawables import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt index a05ac78f84..77e29c7a90 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentPreviewParam import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton @@ -52,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent @@ -142,6 +142,9 @@ fun TimelineItemVoiceView( modifier = Modifier .weight(1f) .height(34.dp), + isPlaying = state.isPlaying, + durationMs = state.durationMs, + playbackSpeed = state.playbackSpeed, seekEnabled = !isTalkbackActive(), onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) }, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index 96d1787085..ca207757b9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -245,12 +245,16 @@ class DefaultVoiceMessageComposerPresenter( val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } } val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } } + val recorderDuration = (recorderState as VoiceRecorderState.Finished).duration + val duration = playerState.durationMs?.milliseconds ?: recorderDuration + return VoiceMessageState.Preview( isSending = isSending, isPlaying = playerState.isPlaying, showCursor = showCursor, playbackProgress = playerState.progress, time = playerTime, + duration = duration, waveform = waveform, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt index 1e55c45542..81f8c8e7fb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt @@ -63,6 +63,7 @@ class VoiceMessageComposerPlayer( playState = it.playState, currentPosition = it.currentPosition, progress = calcProgress(it), + durationMs = if (it.playState != PlayState.Stopped) it.duration else null, ) }.distinctUntilChanged() @@ -183,17 +184,20 @@ class VoiceMessageComposerPlayer( * @property playState Whether this player is currently playing. See [PlayState]. * @property currentPosition The elapsed time of this player in milliseconds. * @property progress The progress of this player between 0 and 1. + * @property durationMs The player-reported duration in milliseconds, when known and the player is not stopped. */ data class State( val playState: PlayState, val currentPosition: Long, val progress: Float, + val durationMs: Long?, ) { companion object { val Initial = State( playState = PlayState.Stopped, currentPosition = 0L, progress = 0f, + durationMs = null, ) } diff --git a/features/messages/impl/src/main/res/values-pl/translations.xml b/features/messages/impl/src/main/res/values-pl/translations.xml index 915eedf61e..1ea8dec888 100644 --- a/features/messages/impl/src/main/res/values-pl/translations.xml +++ b/features/messages/impl/src/main/res/values-pl/translations.xml @@ -48,6 +48,10 @@ "Udostępnij lokalizację" "Ankieta" "Formatowanie tekstu" + "Czy na pewno chcesz usunąć wiadomość?" + "Powód (opcjonalne)" + "Powód…" + "Potwierdź usunięcie" "Historia wiadomości jest obecnie niedostępna." "Historia wiadomości jest niedostępna w tym pokoju. Zweryfikuj to urządzenie, aby zobaczyć historię wiadomości." "Czy chcesz zaprosić ich z powrotem?" diff --git a/features/messages/impl/src/main/res/values-ru/translations.xml b/features/messages/impl/src/main/res/values-ru/translations.xml index d1dfceb6a3..83598bc325 100644 --- a/features/messages/impl/src/main/res/values-ru/translations.xml +++ b/features/messages/impl/src/main/res/values-ru/translations.xml @@ -48,6 +48,10 @@ "Поделиться местоположением" "Опрос" "Форматирование текста" + "Вы уверены, что хотите удалить это сообщение?" + "Причина (необязательно)" + "Введите…" + "Подтвердите удаление" "В настоящее время история сообщений недоступна в этой комнате." "История сообщений в этой комнате недоступна. Проверьте это устройство, чтобы увидеть историю сообщений." "Хотите пригласить их снова?" diff --git a/features/messages/impl/src/main/res/values-zh/translations.xml b/features/messages/impl/src/main/res/values-zh/translations.xml index 609db9f40b..fd339cf906 100644 --- a/features/messages/impl/src/main/res/values-zh/translations.xml +++ b/features/messages/impl/src/main/res/values-zh/translations.xml @@ -46,6 +46,10 @@ "共享位置" "投票" "文本格式化" + "确定要移除此消息?" + "理由(可选)" + "输入…" + "确认移除" "消息历史记录当前不可用。" "消息历史在此房间不可用。请验证此设备以查看。" "你想邀请他们回来吗?" diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 0c45fc01e1..392b90d067 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -47,6 +47,12 @@ "Share location" "Poll" "Text Formatting" + "Are you sure you wish to remove this message?" + "Reason (optional)" + "Enter…" + "Reason…" + "(Optional)" + "Confirm removal" "Message history is currently unavailable." "Message history is unavailable in this room. Verify this device to see your message history." "Would you like to invite them back?" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 4c0abc0a34..bddca0178b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -45,6 +45,7 @@ import io.element.android.features.messages.test.timeline.voicemessages.composer import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -78,10 +79,12 @@ import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_CAPTION +import io.element.android.libraries.matrix.test.A_REASON import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.matrix.test.A_TRANSACTION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.core.FakeSendHandle @@ -125,7 +128,6 @@ import org.junit.Test import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -@Suppress("LargeClass") class MessagesPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -557,13 +559,86 @@ class MessagesPresenterTest { val initialState = awaitItem() val messageEvent = aMessageEvent() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, messageEvent)) - awaitItem() + advanceUntilIdle() + val confirmingState = expectMostRecentItem() + assertThat(confirmingState.redactEventAction).isEqualTo(MessagesState.ConfirmingRedaction(messageEvent.eventId!!)) + assert(redactEventLambda).isNeverCalled() + confirmingState.eventSink(MessagesEvent.ConfirmRedact(A_REASON)) + advanceUntilIdle() + assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) + assert(redactEventLambda) + .isCalledOnce() + .with(value(messageEvent.eventOrTransactionId), value(A_REASON)) + } + } + + @Test + fun `present - handle action redact - a blank reason is not sent`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val liveTimeline = FakeTimeline() + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + liveTimeline.redactEventLambda = redactEventLambda + val presenter = createMessagesPresenter( + timeline = liveTimeline, + coroutineDispatchers = coroutineDispatchers, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + val messageEvent = aMessageEvent() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, messageEvent)) + advanceUntilIdle() + expectMostRecentItem().eventSink(MessagesEvent.ConfirmRedact(" ")) + advanceUntilIdle() + assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) assert(redactEventLambda) .isCalledOnce() .with(value(messageEvent.eventOrTransactionId), value(null)) } } + @Test + fun `present - handle action redact - cancelling does not redact`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val liveTimeline = FakeTimeline() + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + liveTimeline.redactEventLambda = redactEventLambda + val presenter = createMessagesPresenter( + timeline = liveTimeline, + coroutineDispatchers = coroutineDispatchers, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, aMessageEvent())) + advanceUntilIdle() + expectMostRecentItem().eventSink(MessagesEvent.CancelRedact) + advanceUntilIdle() + assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) + assert(redactEventLambda).isNeverCalled() + } + } + + @Test + fun `present - handle action redact - a message which was never sent is redacted without asking`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val liveTimeline = FakeTimeline() + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + liveTimeline.redactEventLambda = redactEventLambda + val presenter = createMessagesPresenter( + timeline = liveTimeline, + coroutineDispatchers = coroutineDispatchers, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + val localEcho = aMessageEvent(eventId = null, transactionId = A_TRANSACTION_ID) + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, localEcho)) + advanceUntilIdle() + assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) + assert(redactEventLambda) + .isCalledOnce() + .with(value(localEcho.eventOrTransactionId), value(null)) + } + } + @Test fun `present - handle action report content`() = runTest { val onReportContentClickLambda = lambdaRecorder { _: EventId, _: UserId -> } @@ -1484,7 +1559,12 @@ class MessagesPresenterTest { clipboardHelper = clipboardHelper, htmlConverterProvider = FakeHtmlConverterProvider(), buildMeta = aBuildMeta(), - timelineController = TimelineController(joinedRoom, timeline), + timelineController = TimelineController( + room = joinedRoom, + liveTimeline = timeline, + roomCoroutineScope = backgroundScope, + dispatchers = coroutineDispatchers, + ), permalinkParser = permalinkParser, analyticsService = analyticsService, encryptionService = encryptionService, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index f662da0eb0..677b2ac10b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -90,7 +90,6 @@ import org.junit.Test import org.robolectric.annotation.Config import kotlin.time.Duration.Companion.milliseconds -@Suppress("LargeClass") class MessagesViewTest : RobolectricTest() { @Test fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index e987a32480..66e57da1fa 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -54,7 +54,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -@Suppress("LargeClass") class ActionListPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 8370ec516f..c9f6aa9631 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -82,7 +82,6 @@ import org.junit.Test import java.io.File import kotlin.io.path.createTempFile -@Suppress("LargeClass") class AttachmentsPreviewPresenterTest : RobolectricTest() { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt index faf0bbe6ed..a8f6c338f5 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt @@ -65,6 +65,7 @@ import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -307,7 +308,12 @@ class MessageComposerPresenterSlashCommandTest { permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permalinkParser = permalinkParser, permalinkBuilder = permalinkBuilder, - timelineController = TimelineController(room, timeline), + timelineController = TimelineController( + room = room, + liveTimeline = timeline, + roomCoroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(), + ), draftService = draftService, mentionSpanProvider = mentionSpanProvider, pillificationHelper = textPillificationHelper, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 4170ae41ab..3253e6088d 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -113,6 +113,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.waitForPredicate import io.mockk.mockk import kotlinx.collections.immutable.ImmutableList @@ -126,7 +127,6 @@ import org.junit.Test import uniffi.wysiwyg_composer.MentionsState import java.io.File -@Suppress("LargeClass") class MessageComposerPresenterTest : RobolectricTest() { @get:Rule val warmUpRule = WarmUpRule() @@ -1707,7 +1707,12 @@ class MessageComposerPresenterTest : RobolectricTest() { permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permalinkParser = permalinkParser, permalinkBuilder = permalinkBuilder, - timelineController = TimelineController(room, timeline), + timelineController = TimelineController( + room = room, + liveTimeline = timeline, + roomCoroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(), + ), draftService = draftService, mentionSpanProvider = mentionSpanProvider, pillificationHelper = textPillificationHelper, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt index bc36766bac..d00062ba6b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt @@ -21,8 +21,10 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test @@ -35,7 +37,10 @@ class TimelineControllerTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) } ) - val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) sut.activeTimelineFlow().test { awaitItem().also { state -> @@ -74,7 +79,10 @@ class TimelineControllerTest { } } ) - val sut = TimelineController(joinedRoom, liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) sut.activeTimelineFlow().test { awaitItem().also { state -> @@ -102,7 +110,10 @@ class TimelineControllerTest { val joinedRoom = FakeJoinedRoom( liveTimeline = liveTimeline ) - val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) sut.activeTimelineFlow().test { awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) @@ -121,7 +132,10 @@ class TimelineControllerTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) } ) - val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) sut.activeTimelineFlow().test { awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) @@ -149,7 +163,10 @@ class TimelineControllerTest { val joinedRoom = FakeJoinedRoom( liveTimeline = liveTimeline ) - val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) assertThat(sut.timelineItems().first()).hasSize(1) } @@ -171,7 +188,10 @@ class TimelineControllerTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) } ) - val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) sut.activeTimelineFlow().test { sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> @@ -196,7 +216,10 @@ class TimelineControllerTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) } ) - val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) sut.activeTimelineFlow().test { awaitItem().also { state -> @@ -220,12 +243,14 @@ class TimelineControllerTest { } } -internal fun createTimelineController( +internal fun TestScope.createTimelineController( room: FakeJoinedRoom = FakeJoinedRoom(liveTimeline = FakeTimeline()), liveTimeline: Timeline = FakeTimeline(name = "live"), ): TimelineController { return TimelineController( room = room, - liveTimeline = liveTimeline + liveTimeline = liveTimeline, + roomCoroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(), ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 290246ffb9..aa4198d156 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -103,7 +103,6 @@ import java.util.Date import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -@Suppress("LargeClass") @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) class TimelinePresenterTest { @get:Rule @@ -1079,7 +1078,11 @@ class TimelinePresenterTest { val focusedState = consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }.last() assertThat(focusedState.isLive).isFalse() initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) - advanceUntilIdle() + + // Give some time for any potential pagination to be called, but it should not be called because the focused event is not rendered yet. + // Also, given enough time, a new state should be emitted with isLive = false, because we switched to a focused event timeline. + runCurrent() + assert(paginateLambda).isNeverCalled() assertThat(expectMostRecentItem().isLive).isFalse() } @@ -1906,7 +1909,12 @@ class TimelinePresenterTest { sendPollResponseAction = sendPollResponseAction, sessionPreferencesStore = sessionPreferencesStore, timelineItemIndexer = timelineItemIndexer, - timelineController = TimelineController(room, timeline), + timelineController = TimelineController( + room = room, + liveTimeline = timeline, + roomCoroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(), + ), resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter, typingNotificationPresenter = { aTypingNotificationState() }, roomCallStatePresenter = { aStandByCallState() }, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 0c6c81a513..15b624aeff 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -83,7 +83,6 @@ import org.junit.Test import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes -@Suppress("LargeClass") class TimelineItemContentMessageFactoryTest : RobolectricTest() { @Test fun `test create OtherMessageType`() = runTest { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt index 62e596bc21..cdce1f2483 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -@Suppress("LargeClass") class TypingNotificationPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index fb44bb4e80..e4c3310089 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -63,7 +63,6 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -@Suppress("LargeClass") class DefaultVoiceMessageComposerPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -104,6 +103,7 @@ class DefaultVoiceMessageComposerPresenterTest { companion object { private val RECORDING_DURATION = 1.seconds + private val PLAYER_DURATION = 10.seconds private val FIRST_LEVEL_DURATION = 500.milliseconds private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toImmutableList()) private val FIRST_RECORDING_STATE = VoiceMessageState.Recording(FIRST_LEVEL_DURATION, listOf(0.1f).toImmutableList()) @@ -267,6 +267,7 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState()) + assertThat((finalState.voiceMessageState as VoiceMessageState.Preview).duration).isEqualTo(RECORDING_DURATION) startRecordResult.assertions().isCalledOnce() stopRecordResult.assertions().isCalledOnce().with(value(false)) deleteRecordingResult.assertions().isNeverCalled() @@ -303,6 +304,7 @@ class DefaultVoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play)) val finalState = awaitItem().also { assertThat(it.voiceMessageState).isEqualTo(aPlayingState()) + assertThat((it.voiceMessageState as VoiceMessageState.Preview).duration).isEqualTo(PLAYER_DURATION) } startRecordResult.assertions().isCalledOnce() stopRecordResult.assertions().isCalledOnce().with(value(false)) @@ -339,17 +341,30 @@ class DefaultVoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f))) awaitItem().apply { - assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true)) + assertThat(voiceMessageState).isEqualTo( + aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true) + ) } awaitItem().apply { - assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true)) + assertThat(voiceMessageState).isEqualTo( + aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true, duration = PLAYER_DURATION) + ) + } + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo( + aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true, duration = PLAYER_DURATION) + ) eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f))) } awaitItem().apply { - assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true)) + assertThat(voiceMessageState).isEqualTo( + aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true, duration = PLAYER_DURATION) + ) } val finalState = awaitItem().apply { - assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true)) + assertThat(voiceMessageState).isEqualTo( + aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true, duration = PLAYER_DURATION) + ) } testPauseAndDestroy(finalState) @@ -828,6 +843,7 @@ class DefaultVoiceMessageComposerPresenterTest { playbackProgress: Float = 0f, isSending: Boolean = false, time: Duration = RECORDING_DURATION, + duration: Duration = RECORDING_DURATION, showCursor: Boolean = false, waveform: List = voiceRecorder.waveform, ) = VoiceMessageState.Preview( @@ -835,6 +851,7 @@ class DefaultVoiceMessageComposerPresenterTest { playbackProgress = playbackProgress, isSending = isSending, time = time, + duration = duration, showCursor = showCursor, waveform = waveform.toImmutableList(), ) @@ -845,6 +862,7 @@ class DefaultVoiceMessageComposerPresenterTest { playbackProgress = 0.1f, showCursor = true, time = RECORDING_DURATION, + duration = PLAYER_DURATION, ) private fun aPausedState() = diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt index 6a201fe3fa..8b32ef993c 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -45,7 +45,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.time.Duration.Companion.milliseconds -@Suppress("LargeClass") class NotificationSettingsPresenterTest { @Test fun `present - ensures initial state is correct`() = runTest { diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt index 51a6f97ab0..8d6ef8d518 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt @@ -68,7 +68,6 @@ import org.junit.Rule import org.junit.Test import kotlin.time.Duration.Companion.milliseconds -@Suppress("LargeClass") @ExperimentalCoroutinesApi class RoomDetailsPresenterTest { @get:Rule diff --git a/features/roomdetailsedit/impl/src/test/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenterTest.kt b/features/roomdetailsedit/impl/src/test/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenterTest.kt index 656cc92265..5d46f26230 100644 --- a/features/roomdetailsedit/impl/src/test/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenterTest.kt +++ b/features/roomdetailsedit/impl/src/test/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenterTest.kt @@ -50,7 +50,6 @@ import org.junit.Rule import org.junit.Test import java.io.File -@Suppress("LargeClass") @ExperimentalCoroutinesApi class RoomDetailsEditPresenterTest { @get:Rule diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt index 0b5133fe6a..8cdba871be 100644 --- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt @@ -43,7 +43,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.test.runTest import org.junit.Test -@Suppress("LargeClass") class SecurityAndPrivacyPresenterTest { @Test fun `present - initial states`() = runTest { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6602dbdf14..fb3a7f37f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ android_gradle_plugin = "9.3.2" # When updating this, please also update the version in the file ./idea/kotlinc.xml kotlin = "2.4.10" -kotlinpoet = "2.3.0" +kotlinpoet = "2.4.0" ksp = "2.3.11" firebaseAppDistribution = "5.3.0" @@ -32,7 +32,7 @@ accompanist = "0.37.3" # Test test_core = "1.7.0" -roborazzi = "1.73.0" +roborazzi = "1.74.0" # Jetbrain datetime = "0.8.0" @@ -53,7 +53,7 @@ maplibre-compose = "0.15.0" dependencyAnalysis = "3.19.1" # DI -metro = "1.4.2" +metro = "1.4.3" # Auto service autoservice = "1.1.1" @@ -168,7 +168,7 @@ test_arch_core = "androidx.arch.core:core-testing:2.2.0" test_junit = "junit:junit:4.13.2" test_runner = "androidx.test:runner:1.7.0" test_mockk = "io.mockk:mockk:1.14.11" -test_konsist = "com.lemonappdev:konsist:0.17.3" +test_konsist = "com.github.jmartinesp:konsist:0.18.0" test_turbine = "app.cash.turbine:turbine:1.2.1" test_truth = "com.google.truth:truth:1.4.5" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.22" @@ -205,7 +205,7 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } -sqlcipher = "net.zetetic:sqlcipher-android:4.18.0" +sqlcipher = "net.zetetic:sqlcipher-android:4.19.0" sqlite = "androidx.sqlite:sqlite-ktx:2.7.0" unifiedpush = "org.unifiedpush.android:connector:3.3.5" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" @@ -238,7 +238,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0" metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" } # Element Call -element_call_embedded = "io.element.android:element-call-embedded:0.25.0" +element_call_embedded = "io.element.android:element-call-embedded:0.26.0" # Auto services google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } @@ -272,7 +272,7 @@ paparazzi = "app.cash.paparazzi:2.0.0-alpha05" roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } -sonarqube = "org.sonarqube:7.4.0.8496" +sonarqube = "org.sonarqube:7.5.0.8588" licensee = "app.cash.licensee:1.14.1" compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } gms_google_services = { id = "com.google.gms.google-services", version = "4.5.0" } diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 7020b5dba6..7c1f7cd3c4 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -19,6 +19,12 @@ android { buildFeatures { buildConfig = true } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { @@ -36,5 +42,5 @@ dependencies { implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) - testCommonDependencies(libs) + testCommonDependencies(libs, true) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt index 1ce241d5d9..493ad87bc3 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt @@ -47,6 +47,7 @@ fun TextFieldDialog( maxLines: Int = minLines, content: String? = null, label: String? = null, + supportingText: String? = null, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, submitText: String = stringResource(CommonStrings.action_ok), destructiveSubmit: Boolean = false, @@ -84,6 +85,7 @@ fun TextFieldDialog( TextFieldListItem( placeholder = placeholder.orEmpty(), label = label, + supportingText = supportingText, text = textFieldContents, onTextChange = { error = if (!validation(it.text)) onValidationErrorMessage else null diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt index e149af5890..f2cace5eaf 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt @@ -57,6 +57,7 @@ fun TextFieldListItem( minLines: Int = 1, maxLines: Int = minLines, label: String? = null, + supportingText: String? = null, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, ) { @@ -66,7 +67,7 @@ fun TextFieldListItem( placeholder = placeholder, label = label, validity = if (error != null) TextFieldValidity.Invalid else TextFieldValidity.None, - supportingText = error, + supportingText = error ?: supportingText, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, maxLines = maxLines, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeek.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeek.kt new file mode 100644 index 0000000000..797fee0470 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeek.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. + */ + +package io.element.android.libraries.designsystem.components.media + +import android.view.MotionEvent + +/** + * Interprets a waveform pointer event and reports whether it was consumed. + * + * @param action [MotionEvent] action, such as [MotionEvent.ACTION_DOWN]. + * @param x Horizontal position of the pointer, in pixels, relative to the waveform canvas. + * @param waveformWidthPx Width of the drawn waveform, in pixels. Touches past this are ignored. + * @param currentSeekProgress In-progress drag progress (`0` to `1`), or `null` if no drag is active. + * @param onDisallowParentIntercept Called with `true` when a drag starts, and `false` when it ends, + * so a parent scroller does not steal the gesture. + * @param onUpdateSeekProgress Called with the current drag progress, or `null` when the drag ends. + * @param onCommitSeek Called with the final progress when the user lifts their finger after a drag. + * @return `true` when the event was consumed by the waveform. + */ +internal fun handleWaveformPointerEvent( + action: Int, + x: Float, + waveformWidthPx: Float, + currentSeekProgress: Float?, + onDisallowParentIntercept: (Boolean) -> Unit, + onUpdateSeekProgress: (Float?) -> Unit, + onCommitSeek: (Float) -> Unit, +): Boolean { + return when (action) { + MotionEvent.ACTION_DOWN -> { + val progress = seekProgressFromPointerX(x, waveformWidthPx) + if (progress != null) { + onDisallowParentIntercept(true) + onUpdateSeekProgress(progress) + true + } else { + false + } + } + MotionEvent.ACTION_MOVE -> { + val progress = seekProgressFromPointerX(x, waveformWidthPx) + if (progress != null) { + onUpdateSeekProgress(progress) + } + true + } + MotionEvent.ACTION_UP -> { + onDisallowParentIntercept(false) + currentSeekProgress?.let(onCommitSeek) + onUpdateSeekProgress(null) + true + } + MotionEvent.ACTION_CANCEL -> { + onDisallowParentIntercept(false) + onUpdateSeekProgress(null) + true + } + else -> false + } +} + +internal fun seekProgressFromPointerX(x: Float, waveformWidthPx: Float): Float? { + if (x in 0F..waveformWidthPx) { + return x / waveformWidthPx + } + return null +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt new file mode 100644 index 0000000000..aacc8ccc1c --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.media + +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Maximum gap, in wall-clock milliseconds, between two progress values that we still treat as + * "the same place" while audio is playing. + * + * Player progress is sampled on a timer, while the waveform cursor is animated every frame. + * A gap smaller than this is expected sampling lag and should be ignored. A larger gap means a + * real jump (seek acknowledged, pause, or the animation has drifted) and the cursor should snap. + * + * The comparison uses wall-clock time, so faster playback makes the same progress-fraction + * delta count as a smaller time gap. + */ +internal const val PLAYING_SYNC_THRESHOLD_MS = 200L + +/** + * When media duration is unknown (`durationMs <= 0`), progress is compared as a 0–1 fraction + * instead of milliseconds. Values this close are treated as equal. + */ +private const val ZERO_DURATION_PROGRESS_EPSILON = 0.01f + +/** + * Whether a player progress sample should update the cursor, or be ignored because a user seek + * has not been acknowledged yet. + * + * After a seek, the player can keep emitting samples from the old position. Those are dropped + * until a sample is close enough to [pendingSeek]. + * + * @param playbackProgress Latest player progress, from 0 to 1. + * @param pendingSeek Target progress of an in-flight user seek, or `null` if none. + * @param durationMs Total media duration in milliseconds. Used to convert a progress delta into time. + * @param playbackSpeed Current playback rate (`1` = realtime). Used when converting the delta to wall-clock time. + * @param syncThresholdMs How close the sample must be to [pendingSeek], in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS]. + */ +internal fun shouldApplyPlayerProgress( + playbackProgress: Float, + pendingSeek: Float?, + durationMs: Long, + playbackSpeed: Float, + syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS, +): Boolean { + if (pendingSeek == null) return true + return hasCaughtUpToSeek( + playbackProgress = playbackProgress, + pendingSeek = pendingSeek, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + syncThresholdMs = syncThresholdMs, + ) +} + +/** + * Whether [playbackProgress] is close enough to [pendingSeek] that the player has likely applied + * the seek. + * + * @param playbackProgress Latest player progress, from 0 to 1. + * @param pendingSeek Target progress of the in-flight user seek, from 0 to 1. + * @param durationMs Total media duration in milliseconds. If `<= 0`, [ZERO_DURATION_PROGRESS_EPSILON] is used instead. + * @param playbackSpeed Current playback rate (`1` = realtime). + * @param syncThresholdMs Allowed wall-clock gap in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS]. + */ +internal fun hasCaughtUpToSeek( + playbackProgress: Float, + pendingSeek: Float, + durationMs: Long, + playbackSpeed: Float, + syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS, +): Boolean { + val delta = abs(playbackProgress - pendingSeek) + if (durationMs <= 0L) { + return delta <= ZERO_DURATION_PROGRESS_EPSILON + } + val deltaMs = delta * durationMs / playbackSpeed.coerceAtLeast(0.01f) + return deltaMs <= syncThresholdMs +} + +/** Player progress at or below this is treated as "at the start" when detecting a loop/restart. */ +private const val RESTART_PROGRESS_EPSILON = 0.01f + +/** Animated cursor at or above this is treated as "at the end" when detecting a loop/restart. */ +private const val RESTART_ANIMATED_PROGRESS = 0.9f + +/** + * Whether the animated cursor should jump to the latest player sample instead of continuing its + * linear animation. + * + * Returns `true` when: + * - playback is paused or duration is unknown (there is no animation to follow) + * - the player is ahead of the cursor by more than [syncThresholdMs] + * - playback restarted from the beginning (cursor near the end, player near the start) + * + * Returns `false` when the values already match, or when the player is only slightly ahead + * (or slightly behind) so the in-flight animation can catch up without a visible jump. + * + * @param playbackProgress Latest player progress, from 0 to 1. + * @param animatedProgress Current animated cursor progress, from 0 to 1. + * @param isPlaying Whether media is currently playing. + * @param durationMs Total media duration in milliseconds. Used to convert a progress delta into time. + * @param playbackSpeed Current playback rate (`1` = realtime). Used when converting the delta to wall-clock time. + * @param syncThresholdMs Drift that is still ignored while playing, in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS]. + */ +internal fun shouldSnapToPlayer( + playbackProgress: Float, + animatedProgress: Float, + isPlaying: Boolean, + durationMs: Long, + playbackSpeed: Float, + syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS, +): Boolean { + val delta = abs(playbackProgress - animatedProgress) + if (delta == 0f) return false + if (!isPlaying || durationMs <= 0L) return true + val deltaMs = delta * durationMs / playbackSpeed.coerceAtLeast(0.01f) + if (playbackProgress < animatedProgress) { + return isPlaybackRestart(playbackProgress, animatedProgress) + } + return deltaMs > syncThresholdMs +} + +/** + * Detects a loop: the player has wrapped to the start while the cursor is still near the end. + */ +private fun isPlaybackRestart( + playbackProgress: Float, + animatedProgress: Float, +): Boolean { + return playbackProgress <= RESTART_PROGRESS_EPSILON && animatedProgress >= RESTART_ANIMATED_PROGRESS +} + +/** + * Remaining wall-clock milliseconds to tween the cursor from [progress] to the end of the media. + * + * Used to time the linear playback animation between infrequent player samples. A result of `0` + * means the cursor is already at (or past) the end and should snap rather than tween. + * + * @param progress Current cursor progress, from 0 to 1. + * @param durationMs Total media duration in milliseconds. + * @param playbackSpeed Current playback rate (`1` = realtime). Faster speeds shorten the result. + */ +internal fun remainingPlaybackAnimationMs( + progress: Float, + durationMs: Long, + playbackSpeed: Float, +): Int { + val remainingProgress = (1f - progress).coerceAtLeast(0f) + return (remainingProgress * durationMs / playbackSpeed.coerceAtLeast(0.01f)) + .roundToInt() + .coerceAtLeast(0) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt index fe9507cb45..3c4f51777c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt @@ -8,17 +8,24 @@ package io.element.android.libraries.designsystem.components.media -import android.view.MotionEvent -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius @@ -41,6 +48,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch import kotlin.math.roundToInt private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F @@ -53,6 +61,9 @@ private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F * @param waveform The waveform to display. * @param onSeek Callback when the user seeks the waveform. Called with a value between 0 and 1. * @param modifier The modifier to be applied to the view. + * @param isPlaying Whether playback is currently in progress. Used to drive a linear progress animation. + * @param durationMs The total duration of the media in milliseconds. Used to time the linear animation. + * @param playbackSpeed The current playback speed. Used to time the linear animation. * @param seekEnabled Whether the user can seek the waveform or not. * @param brush The brush to use to draw the waveform. * @param progressBrush The brush to use to draw the progress. @@ -67,6 +78,9 @@ fun WaveformPlaybackView( waveform: ImmutableList, onSeek: (progress: Float) -> Unit, modifier: Modifier = Modifier, + isPlaying: Boolean = false, + durationMs: Long = 0L, + playbackSpeed: Float = 1f, seekEnabled: Boolean = true, brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary), progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary), @@ -75,14 +89,36 @@ fun WaveformPlaybackView( linePadding: Dp = 2.dp, ) { val seekProgress = remember { mutableStateOf(null) } + // Last user-seek target, kept until the player reports a nearby sample. + var pendingSeek by remember { mutableStateOf(null) } var canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) } var canvasSizePx by remember { mutableStateOf(Size(0f, 0f)) } - val progress by remember(playbackProgress, seekProgress.value) { - derivedStateOf { - seekProgress.value ?: playbackProgress - } - } - val progressAnimated = animateFloatAsState(targetValue = progress, label = "progressAnimation") + val progressAnimated = remember { Animatable(playbackProgress) } + // Bumped after a snap or user seek so AnimateCursorWhilePlaying restarts its tween. + var seekGeneration by remember { mutableIntStateOf(0) } + val coroutineScope = rememberCoroutineScope() + + ApplyPlayerProgressToCursor( + playbackProgress = playbackProgress, + isPlaying = isPlaying, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + pendingSeek = pendingSeek, + seekProgress = seekProgress, + progressAnimated = progressAnimated, + onAcknowledgePendingSeek = { pendingSeek = null }, + onCursorSnap = { seekGeneration++ }, + ) + + AnimateCursorWhilePlaying( + isPlaying = isPlaying, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + seekGeneration = seekGeneration, + progressAnimated = progressAnimated, + ) + + val progress = seekProgress.value ?: progressAnimated.value val amplitudeDisplayCount by remember(canvasSize, lineWidth, linePadding) { derivedStateOf { (canvasSize.width.value / (lineWidth.value + linePadding.value)).toInt() @@ -107,30 +143,22 @@ fun WaveformPlaybackView( .let { if (!seekEnabled) return@let it it.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { e -> - return@pointerInteropFilter when (e.action) { - MotionEvent.ACTION_DOWN -> { - if (e.x in 0F..waveformWidthPx) { - requestDisallowInterceptTouchEvent.invoke(true) - seekProgress.value = e.x / waveformWidthPx - true - } else { - false + handleWaveformPointerEvent( + action = e.action, + x = e.x, + waveformWidthPx = waveformWidthPx, + currentSeekProgress = seekProgress.value, + onDisallowParentIntercept = { requestDisallowInterceptTouchEvent.invoke(it) }, + onUpdateSeekProgress = { seekProgress.value = it }, + onCommitSeek = { seek -> + pendingSeek = seek + onSeek(seek) + coroutineScope.launch { + progressAnimated.snapTo(seek) + seekGeneration++ } - } - MotionEvent.ACTION_MOVE -> { - if (e.x in 0F..waveformWidthPx) { - seekProgress.value = e.x / waveformWidthPx - } - true - } - MotionEvent.ACTION_UP -> { - requestDisallowInterceptTouchEvent.invoke(false) - seekProgress.value?.let(onSeek) - seekProgress.value = null - true - } - else -> false - } + }, + ) } } .then(modifier) @@ -149,7 +177,7 @@ fun WaveformPlaybackView( drawRect( brush = progressBrush, size = Size( - width = progressAnimated.value * waveformWidthPx, + width = progress * waveformWidthPx, height = canvasSizePx.height ), blendMode = BlendMode.SrcAtop @@ -158,7 +186,7 @@ fun WaveformPlaybackView( drawRoundRect( brush = cursorBrush, topLeft = Offset( - x = progressAnimated.value * waveformWidthPx, + x = progress * waveformWidthPx, y = 1f ), size = Size( @@ -172,6 +200,103 @@ fun WaveformPlaybackView( } } +/** + * Applies discrete player progress samples to the animated cursor. + * + * Samples that belong to a position before an in-flight user seek are ignored. Once a sample + * is close enough to [pendingSeek], that seek is considered acknowledged. The cursor then + * either snaps to the sample (pause, large jump, or playback restart) or keeps interpolating. + * + * @param playbackProgress Latest player progress, from 0 to 1. + * @param isPlaying Whether media is currently playing. + * @param durationMs Total media duration in milliseconds. + * @param playbackSpeed Current playback rate (`1` = realtime). + * @param pendingSeek Target progress of an in-flight user seek, or `null` if none. + * @param seekProgress Progress while the user is dragging the cursor; non-null means a drag is in progress. + * @param progressAnimated Animated cursor progress. + * @param onAcknowledgePendingSeek Called when a player sample has caught up to [pendingSeek]. + * @param onCursorSnap Called after the cursor jumps, so the playing animation can restart from the new position. + */ +@Composable +private fun ApplyPlayerProgressToCursor( + playbackProgress: Float, + isPlaying: Boolean, + durationMs: Long, + playbackSpeed: Float, + pendingSeek: Float?, + seekProgress: State, + progressAnimated: Animatable, + onAcknowledgePendingSeek: () -> Unit, + onCursorSnap: () -> Unit, +) { + val latestOnAcknowledgePendingSeek by rememberUpdatedState(onAcknowledgePendingSeek) + val latestOnCursorSnap by rememberUpdatedState(onCursorSnap) + LaunchedEffect(playbackProgress, isPlaying, durationMs, playbackSpeed, pendingSeek) { + if (seekProgress.value != null) return@LaunchedEffect + if (!shouldApplyPlayerProgress( + playbackProgress = playbackProgress, + pendingSeek = pendingSeek, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + ) + ) { + return@LaunchedEffect + } + latestOnAcknowledgePendingSeek() + if (shouldSnapToPlayer( + playbackProgress = playbackProgress, + animatedProgress = progressAnimated.value, + isPlaying = isPlaying, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + ) + ) { + progressAnimated.snapTo(playbackProgress) + latestOnCursorSnap() + } + } +} + +/** + * Animates the cursor linearly from its current position to the end of the media. + * + * Player samples are relatively infrequent, so between them we interpolate using the remaining + * duration and [playbackSpeed]. Changing [seekGeneration] (after a snap or user seek) cancels + * the current tween and starts a new one from the updated cursor position. + * + * @param isPlaying Whether media is currently playing. Animation only runs while this is true. + * @param durationMs Total media duration in milliseconds. Must be `> 0` to animate. + * @param playbackSpeed Current playback rate (`1` = realtime). Faster speeds shorten the tween. + * @param seekGeneration Counter incremented when the cursor jumps, used to restart this effect. + * @param progressAnimated Animated cursor progress, tweened toward `1`. + */ +@Composable +private fun AnimateCursorWhilePlaying( + isPlaying: Boolean, + durationMs: Long, + playbackSpeed: Float, + seekGeneration: Int, + progressAnimated: Animatable, +) { + LaunchedEffect(isPlaying, durationMs, playbackSpeed, seekGeneration) { + if (isPlaying && durationMs > 0L) { + val remainingMs = remainingPlaybackAnimationMs( + progress = progressAnimated.value, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + ) + if (remainingMs == 0) { + progressAnimated.snapTo(1f) + } else { + progressAnimated.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = remainingMs, easing = LinearEasing), + ) + } + } + } +} + @PreviewsDayNight @Composable internal fun WaveformPlaybackViewPreview() = ElementPreview { diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeekTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeekTest.kt new file mode 100644 index 0000000000..fdb1611b75 --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSeekTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.media + +import android.view.MotionEvent +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class WaveformPlaybackSeekTest { + @Test + fun `a down event inside the waveform starts a seek`() { + val result = handleEvent(action = MotionEvent.ACTION_DOWN, x = 40f) + assertThat(result.consumed).isTrue() + assertThat(result.disallowParentIntercept).isTrue() + assertThat(result.seekProgress).isEqualTo(0.4f) + assertThat(result.committedSeek).isNull() + } + + @Test + fun `a down event outside the waveform is ignored`() { + val result = handleEvent(action = MotionEvent.ACTION_DOWN, x = 150f) + assertThat(result.consumed).isFalse() + assertThat(result.seekProgress).isNull() + assertThat(result.disallowParentIntercept).isNull() + } + + @Test + fun `a move event inside the waveform updates the seek progress`() { + val result = handleEvent(action = MotionEvent.ACTION_MOVE, x = 80f) + assertThat(result.consumed).isTrue() + assertThat(result.seekProgress).isEqualTo(0.8f) + assertThat(result.disallowParentIntercept).isNull() + } + + @Test + fun `a move event outside the waveform is still consumed without updating progress`() { + val result = handleEvent(action = MotionEvent.ACTION_MOVE, x = 150f) + assertThat(result.consumed).isTrue() + assertThat(result.seekProgress).isNull() + } + + @Test + fun `an up event commits the in-progress seek`() { + val result = handleEvent( + action = MotionEvent.ACTION_UP, + currentSeekProgress = 0.6f, + ) + assertThat(result.consumed).isTrue() + assertThat(result.disallowParentIntercept).isFalse() + assertThat(result.committedSeek).isEqualTo(0.6f) + assertThat(result.seekProgress).isNull() + assertThat(result.clearedSeekProgress).isTrue() + } + + @Test + fun `an up event without an in-progress seek does not commit`() { + val result = handleEvent(action = MotionEvent.ACTION_UP, currentSeekProgress = null) + assertThat(result.consumed).isTrue() + assertThat(result.committedSeek).isNull() + assertThat(result.clearedSeekProgress).isTrue() + } + + @Test + fun `a cancel event clears the in-progress seek without committing`() { + val result = handleEvent( + action = MotionEvent.ACTION_CANCEL, + currentSeekProgress = 0.6f, + ) + assertThat(result.consumed).isTrue() + assertThat(result.disallowParentIntercept).isFalse() + assertThat(result.committedSeek).isNull() + assertThat(result.clearedSeekProgress).isTrue() + } + + @Test + fun `unknown motion actions are ignored`() { + val result = handleEvent(action = MotionEvent.ACTION_POINTER_DOWN) + assertThat(result.consumed).isFalse() + assertThat(result.seekProgress).isNull() + assertThat(result.committedSeek).isNull() + } + + @Test + fun `seek progress is null when the pointer is outside the waveform`() { + assertThat(seekProgressFromPointerX(x = -1f, waveformWidthPx = 100f)).isNull() + assertThat(seekProgressFromPointerX(x = 101f, waveformWidthPx = 100f)).isNull() + assertThat(seekProgressFromPointerX(x = 0f, waveformWidthPx = 100f)).isEqualTo(0f) + assertThat(seekProgressFromPointerX(x = 100f, waveformWidthPx = 100f)).isEqualTo(1f) + } + + private fun handleEvent( + action: Int, + x: Float = 0f, + waveformWidthPx: Float = 100f, + currentSeekProgress: Float? = null, + ): HandleResult { + var disallowParentIntercept: Boolean? = null + var seekProgress: Float? = currentSeekProgress + var clearedSeekProgress = false + var committedSeek: Float? = null + val consumed = handleWaveformPointerEvent( + action = action, + x = x, + waveformWidthPx = waveformWidthPx, + currentSeekProgress = currentSeekProgress, + onDisallowParentIntercept = { disallowParentIntercept = it }, + onUpdateSeekProgress = { + seekProgress = it + if (it == null) clearedSeekProgress = true + }, + onCommitSeek = { committedSeek = it }, + ) + return HandleResult( + consumed = consumed, + disallowParentIntercept = disallowParentIntercept, + seekProgress = seekProgress, + clearedSeekProgress = clearedSeekProgress, + committedSeek = committedSeek, + ) + } + + private data class HandleResult( + val consumed: Boolean, + val disallowParentIntercept: Boolean?, + val seekProgress: Float?, + val clearedSeekProgress: Boolean, + val committedSeek: Float?, + ) +} diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSyncTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSyncTest.kt new file mode 100644 index 0000000000..3066f179bd --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSyncTest.kt @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.media + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class WaveformPlaybackSyncTest { + @Test + fun `paused cursor always snaps to a different player sample`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.5f, + animatedProgress = 0.2f, + isPlaying = false, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `paused cursor does not snap when it already matches the player`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.5f, + animatedProgress = 0.5f, + isPlaying = false, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + } + + @Test + fun `playing cursor ignores drift under the sync threshold`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.21f, + animatedProgress = 0.2f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + } + + @Test + fun `playing cursor snaps when the player is ahead beyond the sync threshold`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.3f, + animatedProgress = 0.2f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `playing cursor does not snap back when the player lags behind the animation`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0f, + animatedProgress = 0.05f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + } + + @Test + fun `playing cursor snaps back when playback restarts from the beginning`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0f, + animatedProgress = 1f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `stale player samples are ignored until they catch up to a pending seek`() { + assertThat( + shouldApplyPlayerProgress( + playbackProgress = 0.2f, + pendingSeek = 0.8f, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + assertThat( + shouldApplyPlayerProgress( + playbackProgress = 0.8f, + pendingSeek = 0.8f, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `player samples apply immediately when there is no pending seek`() { + assertThat( + shouldApplyPlayerProgress( + playbackProgress = 0.2f, + pendingSeek = null, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `playing cursor snaps when duration is unknown`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.5f, + animatedProgress = 0.2f, + isPlaying = true, + durationMs = 0, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `playing cursor does not snap when drift is exactly the sync threshold`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.22f, + animatedProgress = 0.2f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + } + + @Test + fun `playing cursor snaps back at the restart progress boundary`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.01f, + animatedProgress = 0.9f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.011f, + animatedProgress = 0.9f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + assertThat( + shouldSnapToPlayer( + playbackProgress = 0f, + animatedProgress = 0.89f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isFalse() + } + + @Test + fun `faster playback treats the same progress delta as a smaller time gap`() { + assertThat( + shouldSnapToPlayer( + playbackProgress = 0.3f, + animatedProgress = 0.2f, + isPlaying = true, + durationMs = 10_000, + playbackSpeed = 8f, + ) + ).isFalse() + } + + @Test + fun `a pending seek is acknowledged when duration is unknown and the sample is close`() { + assertThat( + hasCaughtUpToSeek( + playbackProgress = 0.8f, + pendingSeek = 0.8f, + durationMs = 0, + playbackSpeed = 1f, + ) + ).isTrue() + assertThat( + hasCaughtUpToSeek( + playbackProgress = 0.82f, + pendingSeek = 0.8f, + durationMs = 0, + playbackSpeed = 1f, + ) + ).isFalse() + assertThat( + shouldApplyPlayerProgress( + playbackProgress = 0.805f, + pendingSeek = 0.8f, + durationMs = 0, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `a pending seek is acknowledged at the exact sync threshold`() { + assertThat( + hasCaughtUpToSeek( + playbackProgress = 0.82f, + pendingSeek = 0.8f, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isTrue() + } + + @Test + fun `zero playback speed still produces a finite remaining animation duration`() { + assertThat( + remainingPlaybackAnimationMs( + progress = 0.5f, + durationMs = 10_000, + playbackSpeed = 0f, + ) + ).isEqualTo(500_000) + } + + @Test + fun `remaining animation duration is zero when the cursor is already at the end`() { + assertThat( + remainingPlaybackAnimationMs( + progress = 1f, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isEqualTo(0) + assertThat( + remainingPlaybackAnimationMs( + progress = 1.2f, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isEqualTo(0) + } + + @Test + fun `remaining animation duration scales with leftover progress and speed`() { + assertThat( + remainingPlaybackAnimationMs( + progress = 0.25f, + durationMs = 10_000, + playbackSpeed = 1f, + ) + ).isEqualTo(7_500) + assertThat( + remainingPlaybackAnimationMs( + progress = 0.25f, + durationMs = 10_000, + playbackSpeed = 2f, + ) + ).isEqualTo(3_750) + } +} diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackViewTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackViewTest.kt new file mode 100644 index 0000000000..5ded20522c --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackViewTest.kt @@ -0,0 +1,127 @@ +/* + * 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.designsystem.components.media + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.setSafeContent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test + +class WaveformPlaybackViewTest : RobolectricTest() { + @Test + fun `seeking can be disabled`() = runAndroidComposeUiTest { + setWaveformPlaybackView(seekEnabled = false) + waitForIdle() + } + + @Test + fun `a paused cursor follows a new player sample`() = runAndroidComposeUiTest { + val playbackProgress = mutableFloatStateOf(0.2f) + setWaveformPlaybackView(playbackProgress = { playbackProgress.value }) + playbackProgress.value = 0.8f + waitForIdle() + } + + @Test + fun `playing with a positive duration animates the cursor`() = runAndroidComposeUiTest { + setWaveformPlaybackView( + playbackProgress = { 0f }, + isPlaying = true, + durationMs = 50L, + ) + waitForIdle() + } + + @Test + fun `playing at the end of the media snaps the cursor without tweening`() = runAndroidComposeUiTest { + setWaveformPlaybackView( + playbackProgress = { 1f }, + isPlaying = true, + durationMs = 1_000L, + ) + waitForIdle() + } + + @Test + fun `playing with an unknown duration does not animate`() = runAndroidComposeUiTest { + setWaveformPlaybackView( + playbackProgress = { 0.3f }, + isPlaying = true, + durationMs = 0L, + ) + waitForIdle() + } + + @Test + fun `playing cursor keeps interpolating when the player is only slightly ahead`() = runAndroidComposeUiTest { + val playbackProgress = mutableFloatStateOf(0.2f) + setWaveformPlaybackView( + playbackProgress = { playbackProgress.value }, + isPlaying = true, + durationMs = 10_000L, + ) + playbackProgress.value = 0.21f + waitForIdle() + } + + @Test + fun `an empty waveform still renders with a cursor`() = runAndroidComposeUiTest { + setWaveformPlaybackView( + waveform = persistentListOf(), + showCursor = true, + ) + waitForIdle() + } + + @Test + fun `a short waveform still renders`() = runAndroidComposeUiTest { + setWaveformPlaybackView(waveform = persistentListOf(0.2f, 0.4f, 0.6f)) + waitForIdle() + } +} + +private fun AndroidComposeUiTest.setWaveformPlaybackView( + playbackProgress: () -> Float = { 0.5f }, + showCursor: Boolean = false, + waveform: ImmutableList = WaveFormSamples.realisticWaveForm, + isPlaying: Boolean = false, + durationMs: Long = 0L, + playbackSpeed: Float = 1f, + seekEnabled: Boolean = true, +) { + setSafeContent { + ElementTheme { + WaveformPlaybackView( + playbackProgress = playbackProgress(), + showCursor = showCursor, + waveform = waveform, + onSeek = {}, + modifier = Modifier + .fillMaxWidth() + .height(34.dp), + isPlaying = isPlaying, + durationMs = durationMs, + playbackSpeed = playbackSpeed, + seekEnabled = seekEnabled, + ) + } + } +} diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt index 38517dab33..981f53ebfe 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt @@ -57,7 +57,6 @@ import org.junit.Test import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config -@Suppress("LargeClass") class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() { private lateinit var context: Context private lateinit var fakeMatrixClient: FakeMatrixClient diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt index c293b67975..8b92d881a9 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt @@ -58,7 +58,6 @@ import org.junit.Test import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config -@Suppress("LargeClass") class DefaultRoomLatestEventFormatterTest : RobolectricTest() { private lateinit var context: Context private lateinit var fakeMatrixClient: FakeMatrixClient diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/RtcNotificationContentFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/RtcNotificationContentFormatterTest.kt index 9a79f78413..207de5bf6b 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/RtcNotificationContentFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/RtcNotificationContentFormatterTest.kt @@ -21,7 +21,6 @@ import org.junit.Test import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config -@Suppress("LargeClass") class RtcNotificationContentFormatterTest : RobolectricTest() { private lateinit var context: Context private lateinit var fakeMatrixClient: FakeMatrixClient diff --git a/libraries/htmlrenderer/api/build.gradle.kts b/libraries/htmlrenderer/api/build.gradle.kts new file mode 100644 index 0000000000..a475be7eb0 --- /dev/null +++ b/libraries/htmlrenderer/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.htmlrenderer.api" +} + +dependencies { + api(projects.libraries.matrix.api) + api(libs.jsoup) +} diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/HtmlMessageParser.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/HtmlMessageParser.kt new file mode 100644 index 0000000000..f0c3f67249 --- /dev/null +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/HtmlMessageParser.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.htmlrenderer.api + +import org.jsoup.nodes.Document + +/** + * Parses the HTML [Document] of a formatted message body into a tree of [MessageNode]s + * that a Compose renderer can turn into composables. + * + * The [Document] is expected to have already been sanitized (e.g. via + * `FormattedBody.toHtmlDocument`) so that only supported tags remain. + */ +interface HtmlMessageParser { + /** + * Converts [document] into a [DocumentNode]. Never throws: unknown or empty content + * yields a [DocumentNode] with no children. + */ + fun parse(document: Document): DocumentNode + + companion object { + /** + * Tag used for the string annotation attached to link (``) text ranges in a + * [ParagraphNode.text]. The annotation value is the link target URL. + */ + const val LINK_ANNOTATION_TAG = "url" + + /** + * Tag used for the string annotation attached to inline code (``) text ranges in a + * [ParagraphNode.text]. The annotation value is empty; only the range matters. A `` + * nested inside a `
` is part of the [CodeBlockNode] and is never annotated this way.
+         */
+        const val INLINE_CODE_ANNOTATION_TAG = "inline_code"
+    }
+}
diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MentionNodeContent.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MentionNodeContent.kt
new file mode 100644
index 0000000000..4886dcef23
--- /dev/null
+++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MentionNodeContent.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.htmlrenderer.api
+
+import androidx.compose.runtime.Immutable
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.core.UserId
+
+/**
+ * Describes a mention "pill" referenced from a [ParagraphNode].
+ *
+ * Each mention occupies an inline-content placeholder inside the paragraph's
+ * [ParagraphNode.text]; the renderer looks the id up in [ParagraphNode.inlineContent]
+ * and draws the corresponding pill.
+ */
+@Immutable
+sealed interface MentionNodeContent {
+    /** The text shown inside the pill (e.g. a display name, prefixed with `@` for users). */
+    val displayText: String
+
+    /** A mention of a specific user (``). */
+    data class User(
+        override val displayText: String,
+        val userId: UserId,
+    ) : MentionNodeContent
+
+    /** A mention of a room (``). */
+    data class Room(
+        override val displayText: String,
+        val roomIdOrAlias: RoomIdOrAlias,
+    ) : MentionNodeContent
+
+    /** An `@room` mention notifying everyone in the room. */
+    data class Everyone(
+        override val displayText: String,
+    ) : MentionNodeContent
+}
diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MessageNode.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MessageNode.kt
new file mode 100644
index 0000000000..05be7fd8ea
--- /dev/null
+++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MessageNode.kt
@@ -0,0 +1,95 @@
+/*
+ * 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.htmlrenderer.api
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.text.AnnotatedString
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentMapOf
+
+/**
+ * A node in the parsed representation of a formatted (HTML) message body.
+ *
+ * The tree is produced by [HtmlMessageParser] and consumed by a Compose renderer.
+ * Block-level nodes ([BlockNode]) either contain other block nodes (nested lists, a
+ * quote containing paragraphs, ...) or terminate in a [ParagraphNode] / [CodeBlockNode]
+ * that carries the actual text.
+ */
+@Immutable
+sealed interface MessageNode
+
+/** A block-level node, laid out vertically by the renderer. */
+@Immutable
+sealed interface BlockNode : MessageNode
+
+/**
+ * The root of a parsed message: an ordered list of block-level children.
+ */
+@Immutable
+data class DocumentNode(
+    val children: ImmutableList,
+) : BlockNode
+
+/**
+ * A run of inline content (text with inline formatting) already flattened to an
+ * [AnnotatedString]. This is the only leaf that carries styled text.
+ *
+ * Inline formatting (``, ``, ``, ``, inline ``) is encoded as
+ * [androidx.compose.ui.text.SpanStyle] ranges, and links (``) as string annotations
+ * tagged [HtmlMessageParser.LINK_ANNOTATION_TAG].
+ *
+ * Mentions (pills) are encoded as inline-content placeholders inside [text] (see
+ * [androidx.compose.foundation.text.appendInlineContent]); [inlineContent] maps each
+ * placeholder id to the [MentionNodeContent] the renderer needs to draw the pill.
+ */
+@Immutable
+data class ParagraphNode(
+    val text: AnnotatedString,
+    val inlineContent: ImmutableMap = persistentMapOf(),
+) : BlockNode
+
+/**
+ * A preformatted code block (`
`). Rendered in a monospaced, horizontally
+ * scrollable container. The text is kept raw: no inline styling is applied inside it.
+ */
+@Immutable
+data class CodeBlockNode(
+    val code: String,
+) : BlockNode
+
+/**
+ * A block quote (`
`). Quotes can nest and can contain any block content. + */ +@Immutable +data class QuoteNode( + val children: ImmutableList, +) : BlockNode + +/** + * An ordered (`
    `) or unordered (`