Merge branch 'develop' into fix/5794-account-data-api

This commit is contained in:
Benoit Marty
2026-08-11 14:03:39 +02:00
committed by GitHub
650 changed files with 4781 additions and 3197 deletions
+2 -2
View File
@@ -51,12 +51,12 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APKs
+2 -2
View File
@@ -56,12 +56,12 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK
+2 -2
View File
@@ -19,12 +19,12 @@ jobs:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
name: Use JDK 21
with:
distribution: 'temurin' # See 'Supported distributions' for available options
+2 -2
View File
@@ -44,13 +44,13 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }}
persist-credentials: false
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
name: Use JDK 21
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+4 -4
View File
@@ -37,13 +37,13 @@ jobs:
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: false
@@ -80,12 +80,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis
+13 -13
View File
@@ -69,12 +69,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
@@ -108,12 +108,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests
@@ -149,12 +149,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run compose tests
@@ -183,12 +183,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug
@@ -228,12 +228,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt
@@ -269,12 +269,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check
@@ -336,7 +336,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
- uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
upload_reports:
name: Project Check Suite
+2 -2
View File
@@ -54,13 +54,13 @@ jobs:
with:
persist-credentials: false
- name: ☕️ Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
# Add gradle cache, this should speed up the process
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots
+6 -6
View File
@@ -38,12 +38,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -82,12 +82,12 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create Enterprise app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -126,12 +126,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create APKs
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
+2 -2
View File
@@ -45,12 +45,12 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build debug code and test fixtures
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
+2 -2
View File
@@ -63,12 +63,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: ☕️ Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
+1
View File
@@ -60,6 +60,7 @@ dependencies {
testCommonDependencies(libs)
testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
@@ -30,6 +30,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
@@ -65,6 +67,7 @@ class LoggedInPresenter(
private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
private val featureFlagService: FeatureFlagService,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<LoggedInState> {
private val localNetworkPermissionsPresenter: PermissionsPresenter =
@@ -120,6 +123,16 @@ class LoggedInPresenter(
}.launchIn(this)
}
LaunchedEffect(Unit) {
// Keep automatic call status (m.call) in sync with the feature flag and homeserver support.
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
.onEach { isUserStatusEnabled ->
val enabled = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
matrixClient.enableAutomaticCallStatus(enabled)
}
.launchIn(this)
}
val networkConnectivity by networkMonitor.connectivity.collectAsState()
LaunchedEffect(networkConnectivity) {
if (networkConnectivity == NetworkStatus.Connected) {
@@ -17,6 +17,9 @@ import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -48,6 +51,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -127,6 +131,7 @@ class LoggedInPresenterTest {
buildMeta = buildMeta,
networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = FakeFeatureFlagService(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
@@ -346,6 +351,57 @@ class LoggedInPresenterTest {
}
}
@Test
fun `present - does not enable automatic call status when server does not support it`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
).apply {
isUserStatusSupportedResult = Result.success(false)
}
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to true),
),
).test {
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledOnce()
.with(value(false))
}
}
@Test
fun `present - reacts to user status flag being toggled at runtime`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
).apply {
isUserStatusSupportedResult = Result.success(true)
}
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to false),
)
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = featureFlagService,
).test {
awaitItem()
// Turning the flag on at runtime enables it.
featureFlagService.setFeatureEnabled(FeatureFlags.UserStatus, true)
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledExactly(2)
.withSequence(
listOf(value(false)),
listOf(value(true)),
)
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
@@ -362,6 +418,7 @@ class LoggedInPresenterTest {
),
buildMeta: BuildMeta = aBuildMeta(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = matrixClient,
@@ -373,6 +430,7 @@ class LoggedInPresenterTest {
buildMeta = buildMeta,
networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = featureFlagService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
)
}
+6 -2
View File
@@ -303,6 +303,8 @@ Follow these steps to install and configure the plugin and templates:
- Click on OK
4. Configure generate-module-from-template plugin :
- Navigate to AS/Settings/Tools/Module Template Settings
- If a `FeatureModule` template is already listed, select it and remove it, since the plugin keeps its own copy of the
imported template and will not pick up changes made to `FeatureModule.json` on its own
- Click on + / Import From File
- Pick the `tools/templates/FeatureModule.json`
@@ -321,8 +323,10 @@ Example for a new feature called RoomDetails:
5. The modules api/impl should be created under `features/roomdetails` directory.
6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle).
7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`.
To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`.
Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package.
To use it, just right click on the package where you want to generate classes, and click on `New` / `Template Presentation Classes`.
Fill the text field with the base name of the classes: the generated files are `<BaseName>Event.kt`, `<BaseName>State.kt`,
`<BaseName>StateProvider.kt`, `<BaseName>Presenter.kt`, `<BaseName>Node.kt` and `<BaseName>View.kt`. For instance
`PreferencesRoot` in the `features/preferences/impl/root` package.
Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要停用您的帳號。此動作無法還原。"</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要刪除您的帳號。此動作無法還原。"</string>
<string name="screen_deactivate_account_delete_all_messages">"刪除我所有的訊息"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"警告:未來的使用者可能會看到不完整的對話。"</string>
<string name="screen_deactivate_account_description">"停用您的帳號為 %1$s,它將:"</string>
<string name="screen_deactivate_account_description">"刪除您的帳號為 %1$s,它將:"</string>
<string name="screen_deactivate_account_description_bold_part">"不可逆"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。"</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"永久停用"</string>
<string name="screen_deactivate_account_list_item_2">"將您從所有聊天室移除。"</string>
<string name="screen_deactivate_account_list_item_3">"從我們的身份伺服器將您的帳號資訊刪除。"</string>
<string name="screen_deactivate_account_list_item_4">"註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。"</string>
<string name="screen_deactivate_account_title">"停用帳號"</string>
<string name="screen_deactivate_account_title">"刪除帳號"</string>
</resources>
@@ -10,6 +10,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.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
@@ -17,7 +18,7 @@ import kotlinx.coroutines.flow.Flow
interface EnterpriseService {
val isEnterpriseBuild: Boolean
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
suspend fun tweakMasUrl(url: String, homeserver: String): String
suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String
fun defaultHomeserverList(): List<String>
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
@@ -47,6 +48,11 @@ interface EnterpriseService {
*/
fun overriddenElementWellKnown(): ElementWellKnown?
/**
* Gets the Element Server Suite (ESS) config endpoint URL for the given domain.
*/
fun essConfigEndpointUrl(domain: String): String?
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.api.remoteconfig
import io.element.android.libraries.wellknown.api.ElementWellKnown
/**
* Typealias for the well-known configuration used for enterprise features.
*
* This will allow for easier refactoring in the future when we'll want to move as much of its logic as possible to this module.
*/
typealias RemoteEnterpriseConfig = ElementWellKnown
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.api.remoteconfig
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
/**
* Wrapper to fetch the remote enterprise configuration for a given [SessionId], either from a local cache or from a remote source.
*/
interface RemoteEnterpriseConfigProvider {
suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig>
interface Factory {
fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider
}
}
@@ -14,6 +14,7 @@ import dev.zacsweers.metro.ContributesBinding
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.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
@@ -24,7 +25,7 @@ class DefaultEnterpriseService : EnterpriseService {
override val isEnterpriseBuild = false
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, homeserver: String) = url
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url
override fun defaultHomeserverList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
@@ -48,4 +49,6 @@ class DefaultEnterpriseService : EnterpriseService {
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
override fun overriddenElementWellKnown(): ElementWellKnown? = null
override fun essConfigEndpointUrl(domain: String): String? = null
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.impl.remoteconfig
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
@ContributesBinding(AppScope::class)
class DefaultRemoteEnterpriseConfigProvider : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return WellknownRetrieverResult.NotFound
}
@ContributesBinding(AppScope::class)
class Factory : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider {
return DefaultRemoteEnterpriseConfigProvider()
}
}
}
@@ -5,10 +5,12 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.scanner
package io.element.android.features.enterprise.impl.scanner
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
/**
@@ -16,5 +18,12 @@ import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
*/
@ContributesBinding(AppScope::class)
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
override suspend fun getContentScannerUrl(homeserver: String): Result<String?> = Result.success(null)
override suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?> = Result.success(null)
@ContributesBinding(AppScope::class)
class Factory : ContentScannerUrlProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider {
return DefaultContentScannerUrlProvider()
}
}
}
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color
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.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.tests.testutils.lambda.lambdaError
@@ -31,8 +32,9 @@ class FakeEnterpriseService(
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() },
private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() },
private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() },
) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
@@ -41,8 +43,8 @@ class FakeEnterpriseService(
isEnterpriseUserResult(sessionId)
}
override suspend fun tweakMasUrl(url: String, homeserver: String): String = simulateLongTask {
tweakMasUrlResult(url, homeserver)
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String = simulateLongTask {
tweakMasUrlResult(url, homeserver, urlContentFetcher)
}
override fun defaultHomeserverList(): List<String> {
@@ -85,4 +87,8 @@ class FakeEnterpriseService(
override fun overriddenElementWellKnown(): ElementWellKnown? {
return overrideWellKnownResult()
}
override fun essConfigEndpointUrl(domain: String): String {
return essConfigEndpointUrlResult(domain)
}
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
class FakeRemoteEnterpriseConfigProvider(
private val getResult: (SessionId) -> WellknownRetrieverResult<RemoteEnterpriseConfig> = { WellknownRetrieverResult.NotFound }
) : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return getResult(sessionId)
}
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
class FakeRemoteEnterpriseConfigProviderFactory(
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
) : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider
}
@@ -8,6 +8,8 @@
package io.element.android.features.home.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
@@ -15,9 +17,11 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
@@ -71,10 +88,13 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toEmojiText
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -151,7 +171,7 @@ fun HomeTopBar(
},
// We want a 16dp left padding for the navigationIcon :
// 4dp from default TopAppBarHorizontalPadding
// 8dp from AccountIcon default padding (because of IconButton)
// 8dp from AccountIcon default padding (48dp touch target around 32dp avatar)
// 4dp extra padding using left insets
windowInsets = WindowInsets(left = 4.dp),
)
@@ -316,19 +336,36 @@ private fun AccountIcon(
modifier: Modifier = Modifier,
) {
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
IconButton(
modifier = modifier.then(testTag),
onClick = onClick,
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.then(testTag)
.minimumInteractiveComponentSize()
.clickable(
interactionSource = interactionSource,
onClick = onClick,
indication = ripple(bounded = false),
),
contentAlignment = Alignment.Center,
) {
Box {
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
val avatarModifier = if (statusEmoji != null) {
Modifier.eraseStatusEmojiBackground(
parentSize = AvatarSize.CurrentUserTopBar.dp,
layoutDirection = LocalLayoutDirection.current,
)
} else {
Modifier
}
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action)
@@ -339,11 +376,69 @@ private fun AccountIcon(
null
},
)
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
}
private val statusEmojiBadgeSize = 20.dp
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
private val statusEmojiBadgeOffset = 8.dp
private fun Modifier.eraseStatusEmojiBackground(
parentSize: Dp,
layoutDirection: LayoutDirection,
): Modifier = this
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
} else {
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
},
y = size.height - statusEmojiBadgeRadius.toPx(),
),
radius = statusEmojiBadgeRadius.toPx(),
blendMode = BlendMode.Clear,
)
}
@Composable
private fun StatusEmojiBadge(
emoji: String,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
// Ensure the emoji size is not affected by the user's font scale settings
val fixedFontScaleDensity = remember(density.density) {
Density(density = density.density, fontScale = 1f)
}
Box(
modifier = modifier
.offset(x = statusEmojiBadgeOffset)
.size(statusEmojiBadgeSize),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
Text(
text = emoji,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
}
@@ -432,6 +527,33 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarWithStatusPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
currentUserAndNeighbors = persistentListOf(
aMatrixUser(
id = "@id:domain",
displayName = USER_NAME_ALICE,
displayedStatus = DisplayedStatus.UserSet(UserStatus(emoji = "🌴", text = "Away")),
)
),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
@@ -5,7 +5,7 @@
<string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string>
<string name="banner_new_sound_message">"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."</string>
<string name="banner_new_sound_title">"Odświeżyliśmy Twoje dźwięki"</string>
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="banner_set_up_recovery_submit">"Uzyskaj klucz przywracania"</string>
<string name="banner_set_up_recovery_title">"Utwórz kopię zapasową swoich czatów"</string>
<string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string>
@@ -5,7 +5,7 @@
<string name="banner_battery_optimization_title_android">"Nu primiți notificări?"</string>
<string name="banner_new_sound_message">"Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar."</string>
<string name="banner_new_sound_title">"Am reîmprospătat sunetele"</string>
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura aceast backup și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="banner_set_up_recovery_submit">"Obțineți cheia de recuperare"</string>
<string name="banner_set_up_recovery_title">"Faceți un backup al mesajelor"</string>
<string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Bereits Mitglied"</string>
<string name="screen_invite_users_already_invited">"Bereits eingeladen"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Du hast derzeit keine Chats mit diesen Kontakten. Bestätige, dass du sie in diesen Chat einladen möchtest, bevor du fortfährst."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Du hast derzeit keine Chats mit diesem Kontakt. Bestätige, dass du ihn in diesen Chat einladen möchtest, bevor du fortfährst."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Möchtest du neue Kontakte in diesen Chat einladen?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Willst du einen neuen Kontakt in diesen Chat einladen?"</string>
</resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"已是成員"</string>
<string name="screen_invite_users_already_invited">"已邀請"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"您目前與這些聯絡人沒有任何聊天紀錄。請確認是否要邀請他們加入此聊天室,然後再繼續。"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"您目前與此聯絡人沒有任何聊天紀錄。請確認是否要邀請對方加入此聊天室,然後再繼續。"</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"要邀請新聯絡人加入此聊天室嗎?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"要邀請新聯絡人加入此聊天室嗎?"</string>
</resources>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Skaneeri QR-koodi selle seadmega"</string>
<string name="screen_link_new_device_desktop_submit">"Skaneerimiseks valmis"</string>
<string name="screen_link_new_device_desktop_title">"QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"</string>
<string name="screen_link_new_device_desktop_warning">"Skaneeri QR-koodi peale järgnevate sammude läbitegemist oma seadmes. Ära iialgi skaneeri QR-koodi, mille on saatnud keegi teine."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numbrid ei klapi"</string>
<string name="screen_link_new_device_enter_number_notice">"Sisesta kahekohaline kood"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Skenirajte QR kod ovim uređajem"</string>
<string name="screen_link_new_device_desktop_submit">"Spremno za skeniranje"</string>
<string name="screen_link_new_device_desktop_title">"Otvorite %1$s na stolnom računalu kako biste dobili QR kod"</string>
<string name="screen_link_new_device_desktop_warning">"QR kod skenirajte tek nakon što ste na vlastitom uređaju slijedili gore navedene korake. Nikada ne skenirajte kod koji vam je poslala druga osoba."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Brojevi se ne podudaraju"</string>
<string name="screen_link_new_device_enter_number_notice">"Unesite dvoznamenkasti kod"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Olvassa be a QR-kódot ezzel az eszközzel"</string>
<string name="screen_link_new_device_desktop_submit">"Készen áll a beolvasásra"</string>
<string name="screen_link_new_device_desktop_title">"Nyissa meg az asztali %1$s alkalmazást, hogy megkapja a QR-kódot"</string>
<string name="screen_link_new_device_desktop_warning">"Csak a fenti lépések végrehajtása után olvass be QR-kódot a saját eszközén. Soha ne olvasson be más által küldött kódot."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"A számok nem egyeznek"</string>
<string name="screen_link_new_device_enter_number_notice">"Írja be a kétjegyű kódot"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Ezzel ellenőrizni fogja, hogy a másik eszközzel való kapcsolat biztonságos-e."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Scanați codul QR cu acest dispozitiv"</string>
<string name="screen_link_new_device_desktop_submit">"Gata de scanare"</string>
<string name="screen_link_new_device_desktop_title">"Deschide %1$s pe un computer desktop pentru a obține codul QR"</string>
<string name="screen_link_new_device_desktop_warning">"Scanați un cod QR doar după ce urmați pașii de mai sus pe propriul dispozitiv. Nu scanați niciodată un cod trimis de o altă persoană."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numerele nu se potrivesc"</string>
<string name="screen_link_new_device_enter_number_notice">"Introduceți codul de 2 cifre"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"使用此裝置掃描 QR code"</string>
<string name="screen_link_new_device_desktop_submit">"準備掃描"</string>
<string name="screen_link_new_device_desktop_title">"在桌上型電腦上開啟 %1$s 以取得 QR code"</string>
<string name="screen_link_new_device_desktop_warning">"請務必先在您的裝置上完成上述步驟,之後才掃描 QR code。切勿掃描他人傳送的 QR code。"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"數字不符"</string>
<string name="screen_link_new_device_enter_number_notice">"輸入兩位數代碼"</string>
<string name="screen_link_new_device_enter_number_subtitle">"這將確認您與另一台裝置之間的連線是否安全。"</string>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Der Verlauf deines Live-Standorts wird im Chat gespeichert und bleibt für die Mitglieder sichtbar, auch nachdem du die Freigabe beendet hast."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Wie lange soll der Live-Standort geteilt werden?"</string>
</resources>
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"您的即時位置歷史將儲存於聊天室中,並在工作階段結束後對其他成員可見。"</string>
<string name="screen_share_location_live_location_duration_picker_title">"選擇分享即時位置的時間長度。"</string>
<string name="screen_share_location_live_location_missing_permissions">"您無權在此聊天室分享您的即時位置"</string>
</resources>
@@ -13,13 +13,15 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
@ContributesBinding(AppScope::class)
class DefaultAccountProviderAccessControl(
private val enterpriseService: EnterpriseService,
private val wellknownRetriever: WellknownRetriever,
private val wellknownRetrieverFactory: WellknownRetriever.Factory,
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
) : AccountProviderAccessControl {
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
assertIsAllowedToConnectToAccountProvider(
@@ -38,14 +40,21 @@ class DefaultAccountProviderAccessControl(
) {
if (enterpriseService.isEnterpriseBuild.not()) {
// Ensure that Element Pro is not required for this account provider
val wellKnown = wellknownRetriever.getElementWellKnown(
baseUrl = accountProviderUrl.ensureProtocol(),
).dataOrNull()
if (wellKnown?.enforceElementPro == true) {
throw AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = title,
applicationId = ELEMENT_PRO_APPLICATION_ID,
)
val temporaryMatrixClient = temporaryMatrixClientFactory.create(accountProviderUrl).getOrThrow()
temporaryMatrixClient.use {
val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient)
val wellKnown = wellknownRetriever.getElementWellKnown(
host = accountProviderUrl,
// It's expected this is hardcoded to the well-known endpoint
source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT,
).dataOrNull()
if (wellKnown?.enforceElementPro == true) {
throw AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = title,
applicationId = ELEMENT_PRO_APPLICATION_ID,
)
}
}
}
if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) {
@@ -14,6 +14,8 @@ import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.core.uri.isValidUrl
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
@@ -34,8 +36,13 @@ class HomeserverResolver(
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
val flowContext = currentCoroutineContext()
val trimmedUserInput = userInput.trim()
if (trimmedUserInput.length < 4) return@flow
val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/")
val searchTerm = when {
MatrixPatterns.isUserId(trimmedUserInput) -> UserId(trimmedUserInput).domainName.orEmpty()
trimmedUserInput.startsWith("@") -> return@flow
else -> trimmedUserInput
}
if (searchTerm.length < 4) return@flow
val candidateBase = searchTerm.ensureProtocol().removeSuffix("/")
val list = getUrlCandidates(candidateBase)
val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>())
// Run all the requests in parallel
@@ -13,6 +13,8 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
import timber.log.Timber
@@ -22,24 +24,32 @@ interface WebClientUrlForAuthenticationRetriever {
@ContributesBinding(AppScope::class)
class DefaultWebClientUrlForAuthenticationRetriever(
private val wellknownRetriever: WellknownRetriever,
private val wellknownRetrieverFactory: WellknownRetriever.Factory,
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
) : WebClientUrlForAuthenticationRetriever {
override suspend fun retrieve(homeServerUrl: String): String {
if (homeServerUrl != AuthenticationConfig.MATRIX_ORG_URL) {
Timber.w("Temporary account creation flow is only supported on matrix.org")
throw AccountCreationNotSupported()
}
val wellknown = wellknownRetriever.getElementWellKnown(homeServerUrl).dataOrNull()
?: throw AccountCreationNotSupported()
val registrationHelperUrl = wellknown.registrationHelperUrl
return if (registrationHelperUrl != null) {
registrationHelperUrl.toUri()
.buildUpon()
.appendQueryParameter("hs_url", homeServerUrl)
.build()
.toString()
} else {
throw AccountCreationNotSupported()
val temporaryMatrixClient = temporaryMatrixClientFactory.create(homeServerUrl).getOrThrow()
return temporaryMatrixClient.use {
val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient)
val wellknown = wellknownRetriever.getElementWellKnown(
host = homeServerUrl,
source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT
).dataOrNull() ?: throw AccountCreationNotSupported()
val registrationHelperUrl = wellknown.registrationHelperUrl
if (registrationHelperUrl != null) {
registrationHelperUrl.toUri()
.buildUpon()
.appendQueryParameter("hs_url", homeServerUrl)
.build()
.toString()
} else {
throw AccountCreationNotSupported()
}
}
}
}
@@ -37,7 +37,14 @@
<string name="screen_login_subtitle">"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."</string>
<string name="screen_login_title">"Willkommen zurück!"</string>
<string name="screen_login_title_with_homeserver">"Anmelden bei %1$s"</string>
<string name="screen_missing_key_backup_open_element_classic">"Element Classic öffnen"</string>
<string name="screen_missing_key_backup_step_1">"Öffne Element Classic auf deinem Gerät"</string>
<string name="screen_missing_key_backup_step_2_android">"Geh zu Einstellungen &gt; Sicherheit &amp; Datenschutz"</string>
<string name="screen_missing_key_backup_step_4">"Folge den Anweisungen, um den Schlüsselspeicher zu aktivieren."</string>
<string name="screen_missing_key_backup_step_5">"Zurück zu %1$s"</string>
<string name="screen_missing_key_backup_title">"Aktiviere deinen Schlüsselspeicher, bevor du mit %1$s fortfährst."</string>
<string name="screen_onboarding_app_version">"Version %1$s"</string>
<string name="screen_onboarding_checking_account">"Konto wird geprüft"</string>
<string name="screen_onboarding_sign_in_manually">"Manuell anmelden"</string>
<string name="screen_onboarding_sign_in_to">"Anmelden bei %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Mit QR-Code anmelden"</string>
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"Vad är adressen till din server?"</string>
<string name="screen_change_server_title">"Välj din server"</string>
<string name="screen_create_account_title">"Skapa konto"</string>
<string name="screen_login_error_deactivated_account">"Detta konto har avaktiverats."</string>
<string name="screen_login_error_deactivated_account">"Detta konto har raderats."</string>
<string name="screen_login_error_invalid_credentials">"Felaktigt användarnamn och/eller lösenord"</string>
<string name="screen_login_error_invalid_user_id">"Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning."</string>
@@ -11,7 +11,7 @@
<string name="screen_account_provider_signup_title">"您即將在 %s 建立帳號"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org 由 Matrix.org 基金會營運,是用於安全、去中心化通訊的公共 Matrix 網路上的大型免費伺服器。"</string>
<string name="screen_change_account_provider_other">"其他"</string>
<string name="screen_change_account_provider_subtitle">"使用不同的帳戶提供者,例如您自己的伺服器或工作帳號。"</string>
<string name="screen_change_account_provider_subtitle">"使用不同的帳號提供者,例如您自己的伺服器或工作帳號。"</string>
<string name="screen_change_account_provider_title">"更改帳號提供者"</string>
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"%1$s 需要 Element Pro 應用程式。請從商店下載。"</string>
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"您的伺服器地址?"</string>
<string name="screen_change_server_title">"選擇您的伺服器"</string>
<string name="screen_create_account_title">"建立帳號"</string>
<string name="screen_login_error_deactivated_account">"這個帳號已被停用。"</string>
<string name="screen_login_error_deactivated_account">"此帳號已被刪除。"</string>
<string name="screen_login_error_invalid_credentials">"不正確的使用者名稱或密碼"</string>
<string name="screen_login_error_invalid_user_id">"此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’"</string>
<string name="screen_login_error_refresh_tokens">"此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。"</string>
@@ -12,10 +12,12 @@ 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.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
import io.element.android.features.wellknown.test.anElementWellKnown
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.FakeTemporaryMatrixClientFactory
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import kotlinx.coroutines.test.runTest
@@ -156,15 +158,18 @@ class DefaultAccountProviderAccessControlTest {
isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver },
defaultHomeserverListResult = { allowedAccountProviders },
),
wellknownRetriever = FakeWellknownRetriever(
getElementWellKnownResult = {
if (elementWellKnown == null) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Success(elementWellKnown)
}
},
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(
wellknownRetriever = FakeWellknownRetriever(
getElementWellKnownResult = { _, _ ->
if (elementWellKnown == null) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Success(elementWellKnown)
}
},
),
),
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
)
private fun DefaultAccountProviderAccessControl.expectNeedElementProException() {
@@ -17,18 +17,19 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.uri.ensureProtocol
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.FakeTemporaryMatrixClientFactory
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
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.WarmUpRule
@@ -158,7 +159,8 @@ class ChangeServerPresenterTest {
@Test
fun `present - change server element pro required error`() = runTest {
val getElementWellKnownResult = lambdaRecorder<String, WellknownRetrieverResult<ElementWellKnown>> {
val getElementWellKnownResult = lambdaRecorder { homeserver: String, _: EnterpriseRemoteConfigSource ->
assertThat(homeserver).isEqualTo(A_HOMESERVER_URL)
WellknownRetrieverResult.Success(
anElementWellKnown(
enforceElementPro = true,
@@ -174,8 +176,6 @@ class ChangeServerPresenterTest {
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL)
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider))
val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
val failureState = awaitItem()
assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle
@@ -183,9 +183,7 @@ class ChangeServerPresenterTest {
assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId
).isEqualTo("io.element.enterprise")
getElementWellKnownResult.assertions()
.isCalledOnce()
.with(value(A_HOMESERVER_URL.ensureProtocol()))
getElementWellKnownResult.assertions().isCalledOnce()
}
}
@@ -269,7 +267,8 @@ class ChangeServerPresenterTest {
accountProviderDataSource = accountProviderDataSource,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
wellknownRetriever = wellknownRetriever,
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever),
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
),
localNetworkPermissionGate = LocalNetworkPermissionGate(
advisor = localNetworkPermissionAdvisor,
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.resolver
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class HomeserverResolverTest {
@Test
fun `resolve - a server name is used as is`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("example.org").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org")),
)
}
@Test
fun `resolve - an url is used as is`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("https://example.org").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org")),
)
}
@Test
fun `resolve - a user id resolves the server name of the user id`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice:example.org").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org")),
)
}
@Test
fun `resolve - a user id with a port resolves the server name of the user id`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org:8448") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice:example.org:8448").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org:8448")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org:8448")),
)
}
@Test
fun `resolve - an incomplete user id does not resolve anything`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice").test {
awaitComplete()
}
sut.resolve("@alice:").test {
awaitComplete()
}
checkResult.assertions().isNeverCalled()
}
@Test
fun `resolve - a user id with a too short server name does not resolve anything`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice:a.b").test {
awaitComplete()
}
checkResult.assertions().isNeverCalled()
}
private fun TestScope.createHomeserverResolver(
checkResult: (String) -> Result<Boolean>,
) = HomeserverResolver(
dispatchers = testCoroutineDispatchers(),
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult),
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
)
}
@@ -20,6 +20,7 @@ import io.element.android.features.login.impl.login.LoginModePresenter
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -30,11 +31,15 @@ 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.FakeTemporaryMatrixClientFactory
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
import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
@@ -304,7 +309,8 @@ private fun createPresenter(
enterpriseService = enterpriseService,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
wellknownRetriever = wellknownRetriever,
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever),
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
),
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
loginModePresenter = loginModePresenter,
@@ -318,9 +324,9 @@ fun createLoginModePresenter(
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor =
io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory: io.element.android.libraries.permissions.api.PermissionsPresenter.Factory =
io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory(),
FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory: PermissionsPresenter.Factory =
FakePermissionsPresenterFactory(),
): LoginModePresenter = LoginModePresenter(
oAuthActionFlow = oAuthActionFlow,
authenticationService = authenticationService,
@@ -28,6 +28,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.MessagesEvent
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.MessagesPresenter
import io.element.android.features.messages.impl.MessagesState
@@ -40,6 +41,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -54,6 +56,7 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -91,6 +94,7 @@ class ThreadedMessagesNode(
private val permalinkParser: PermalinkParser,
private val appNavigationStateService: AppNavigationStateService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs(
val threadRootEventId: ThreadId,
@@ -322,7 +326,15 @@ class ThreadedMessagesNode(
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},
customReactionBottomSheet = {},
customReactionBottomSheet = {
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
},
emojiPickerRenderer = emojiPickerRenderer,
)
},
onThreadsListClick = {},
)
@@ -125,6 +125,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
import io.element.android.libraries.matrix.api.timeline.item.event.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
@@ -207,11 +208,7 @@ fun TimelineItemEventRow(
}
fun onUserDataClick() {
val sender = MatrixUser(
userId = event.senderId,
displayName = event.senderProfile.getDisplayName(),
avatarUrl = event.senderProfile.getAvatarUrl(),
)
val sender = event.senderProfile.toMatrixUser(event.senderId)
onUserDataClick(sender)
}
@@ -804,15 +801,15 @@ private fun MessageEventBubbleContent(
.padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(shape)
val talkbackCompatModifier = if (isTalkbackActive()) {
val talkbackCompatModifier = when {
// Use z-index to make the replied to text being read after the message
// Usually, you'd use traversalIndex for that, but it's not working for some reason
inReplyToModifier.zIndex(1f)
} else {
inReplyToModifier.clickable(onClick = inReplyToClick)
isTalkbackActive() -> inReplyToModifier.zIndex(1f)
inReplyTo is InReplyToDetails.Error -> inReplyToModifier
else -> inReplyToModifier.clickable(onClick = inReplyToClick)
}
val contentHasError = currentContentValidationState.hasError()
val contentHasError = currentContentValidationState.hasError() || inReplyTo is InReplyToDetails.Error
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
Box(
@@ -8,11 +8,16 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import io.element.android.emojibasebindings.Emoji
@@ -54,6 +59,7 @@ fun CustomReactionBottomSheet(
modifier = modifier,
scrollable = false,
) {
ExpandSheetWhenImeIsVisible(sheetState)
emojiPickerRenderer.Render(
state = state.target.emojiPickerState,
onSelectEmoji = ::onEmojiSelectedDismiss,
@@ -67,3 +73,14 @@ fun CustomReactionBottomSheet(
}
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
internal fun ExpandSheetWhenImeIsVisible(sheetState: SheetState) {
val isImeVisible = WindowInsets.isImeVisible
LaunchedEffect(isImeVisible) {
if (isImeVisible) {
sheetState.expand()
}
}
}
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
@@ -20,7 +19,6 @@ import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.media.isPreviewEnabled
import io.element.android.libraries.matrix.api.room.BaseRoom
@@ -32,9 +30,8 @@ class TimelineProtectionPresenter(
private val mediaPreviewService: MediaPreviewService,
private val room: BaseRoom,
private val contentScannerService: ContentScannerService,
private val timelineProtectionStore: TimelineProtectionStore,
) : Presenter<TimelineProtectionState> {
private val allowedEvents = mutableStateSetOf<EventId>()
@Composable
override fun present(): TimelineProtectionState {
val mediaPreviewValue = remember {
@@ -48,7 +45,7 @@ class TimelineProtectionPresenter(
if (isPreviewEnabled) {
ProtectionState.RenderAll
} else {
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
ProtectionState.RenderOnly(eventIds = timelineProtectionStore.allowedEventIds.toImmutableSet())
}
}
}
@@ -56,7 +53,7 @@ class TimelineProtectionPresenter(
fun handleEvent(event: TimelineProtectionEvent) {
when (event) {
is TimelineProtectionEvent.ShowContent -> {
allowedEvents += setOfNotNull(event.eventId)
event.eventId?.let(timelineProtectionStore::allowEvent)
}
is TimelineProtectionEvent.ValidateContent -> {
contentScannerService.scan(event.mediaSources, event.validationState)
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.protection
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.snapshots.SnapshotStateSet
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
interface TimelineProtectionStore {
val allowedEventIds: SnapshotStateSet<EventId>
fun allowEvent(eventId: EventId)
}
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
class DefaultTimelineProtectionStore : TimelineProtectionStore {
private val allowedEvents = mutableStateSetOf<EventId>()
override val allowedEventIds: SnapshotStateSet<EventId> = allowedEvents
override fun allowEvent(eventId: EventId) {
allowedEvents += eventId
}
}
@@ -70,6 +70,10 @@
<string name="screen_room_timeline_reactions_show_more">"Több megjelenítése"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Reakció-összefoglaló megjelenítése"</string>
<string name="screen_room_timeline_read_marker_title">"Új"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="one">"%1$d eltávolított üzenet"</item>
<item quantity="other">"%1$d eltávolított üzenet"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d szobaváltozás"</item>
<item quantity="other">"%1$d szobaváltozás"</item>
@@ -32,7 +32,7 @@
<string name="screen_media_upload_preview_processing">"Przetwarzanie…"</string>
<string name="screen_media_upload_preview_title">"Dodaj media"</string>
<string name="screen_report_content_block_user">"Zablokuj użytkownika"</string>
<string name="screen_report_content_block_user_hint">"Sprawdź, czy chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."</string>
<string name="screen_report_content_block_user_hint">"Zaznacz, jeśli chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."</string>
<string name="screen_report_content_explanation">"Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości."</string>
<string name="screen_report_content_hint">"Powód zgłoszenia treści"</string>
<string name="screen_room_attachment_source_camera">"Kamera"</string>
@@ -7,6 +7,19 @@
<string name="crypto_event_authenticity_unknown_device">"Criptat de un dispozitiv necunoscut sau șters."</string>
<string name="crypto_event_authenticity_unsigned_device">"Criptat de un dispozitiv care nu este verificat de proprietarul său."</string>
<string name="crypto_event_authenticity_unverified_identity">"Criptat de un utilizator neverificat."</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Răsturnați imaginea pe orizontală"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Răsturnat pe orizontală"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Răsturnați imaginea pe verticală"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Răsturnat pe verticală"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Original"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Rotiți imaginea la stânga"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d grad"</item>
<item quantity="few">"%1$d grade"</item>
<item quantity="other">"%1$d grade"</item>
</plurals>
<string name="screen_image_edition_title">"Editați poza"</string>
<string name="screen_media_upload_preview_caption_warning">"Este posibil ca descrierile să nu fie vizibile pentru persoanele care folosesc aplicații mai vechi."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Atingeți pentru a modifica calitatea încărcării videoclipului"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Fișierul nu a putut fi încărcat."</string>
@@ -17,6 +30,7 @@
<string name="screen_media_upload_preview_item_count">"Elementul %1$d din %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimizați calitatea imaginii"</string>
<string name="screen_media_upload_preview_processing">"Se procesează…"</string>
<string name="screen_media_upload_preview_title">"Adăugați media"</string>
<string name="screen_report_content_block_user">"Blocați utilizatorul"</string>
<string name="screen_report_content_block_user_hint">"Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator"</string>
<string name="screen_report_content_explanation">"Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat."</string>
@@ -59,6 +73,11 @@
<string name="screen_room_timeline_reactions_show_more">"Afișați mai mult"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Afișați rezumatul reacțiilor"</string>
<string name="screen_room_timeline_read_marker_title">"Nou"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="one">"%1$d mesaj șters"</item>
<item quantity="few">"%1$d mesaje șterse"</item>
<item quantity="other">"%1$d mesaje șterse"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d schimbare a camerii"</item>
<item quantity="few">"%1$d schimbări ale camerei"</item>
@@ -7,6 +7,17 @@
<string name="crypto_event_authenticity_unknown_device">"由未知或已刪除的裝置加密。"</string>
<string name="crypto_event_authenticity_unsigned_device">"由未經其擁有者驗證的裝置加密。"</string>
<string name="crypto_event_authenticity_unverified_identity">"由未經驗證的使用者加密。"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平翻轉圖片"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"水平翻轉"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"原始狀態"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"垂直翻轉圖片"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"垂直翻轉"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"原始狀態"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"向左旋轉圖片"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="other">"%1$d 度"</item>
</plurals>
<string name="screen_image_edition_title">"編輯照片"</string>
<string name="screen_media_upload_preview_caption_warning">"使用舊應用程式的使用者可能看不到標題。"</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"輕點即可變更影片上傳品質"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"無法上傳檔案。"</string>
@@ -17,6 +28,7 @@
<string name="screen_media_upload_preview_item_count">"第 %1$d 個項目,共 %2$d 個"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"最佳化影像品質"</string>
<string name="screen_media_upload_preview_processing">"正在處理……"</string>
<string name="screen_media_upload_preview_title">"新增媒體"</string>
<string name="screen_report_content_block_user">"封鎖使用者"</string>
<string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string>
<string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</string>
@@ -55,6 +67,9 @@
<string name="screen_room_timeline_reactions_show_more">"更多"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"顯示反應摘要"</string>
<string name="screen_room_timeline_read_marker_title">"新訊息"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="other">"%1$d 則已移除的訊息"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes">
<item quantity="other">"%1$d 個聊天室變更"</item>
</plurals>
@@ -13,8 +13,12 @@ package io.element.android.features.messages.impl.timeline
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToIndex
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
@@ -29,8 +33,14 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
@@ -168,6 +178,61 @@ class TimelineViewTest : RobolectricTest() {
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
}
@Test
fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>()
setTimelineView(
state = aTimelineState(
timelineItems = persistentListOf(
aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = InReplyToDetails.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
senderProfile = aProfileDetailsReady(),
eventContent = MessageContent(
body = "Replied message",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType("Replied message", null),
),
textContent = "Replied message",
),
),
),
eventSink = eventsRecorder,
),
)
eventsRecorder.clear()
onAllNodesWithText("Replied message").onFirst().performClick()
eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
}
@Test
fun `a reply to an event which could not be loaded renders a placeholder and does not emit any Event when clicked`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>()
setTimelineView(
state = aTimelineState(
timelineItems = persistentListOf(
aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = InReplyToDetails.Error(eventId = AN_EVENT_ID, message = "Failed to fetch the event"),
),
),
eventSink = eventsRecorder,
),
onMessageClick = {},
)
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
eventsRecorder.clear()
onNodeWithText("Failed to fetch the event").assertDoesNotExist()
onNodeWithText(activity!!.getString(CommonStrings.error_message_not_found)).assertIsDisplayed().performClick()
eventsRecorder.assertEmpty()
}
@Ignore(
"performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." +
"This needs to be reworked to use a different approach to check the LoadMore event was emitted."
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalTestApi::class)
package io.element.android.features.messages.impl.timeline.components.customreaction
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class ExpandSheetWhenImeIsVisibleTest : RobolectricTest() {
@Test
fun `the sheet is left alone while the keyboard is hidden`() = runAndroidComposeUiTest<ComponentActivity> {
val sheetState = renderWithIme(isImeVisible = false)
assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded)
}
@Test
fun `the sheet is expanded when the keyboard is shown`() = runAndroidComposeUiTest<ComponentActivity> {
val sheetState = renderWithIme(isImeVisible = true)
assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded)
}
}
private fun AndroidComposeUiTest<ComponentActivity>.renderWithIme(isImeVisible: Boolean): SheetState {
lateinit var sheetState: SheetState
lateinit var view: View
setContent {
view = LocalView.current
sheetState = rememberBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
ExpandSheetWhenImeIsVisible(sheetState)
}
runOnIdle {
ViewCompat.dispatchApplyWindowInsets(
view,
WindowInsetsCompat.Builder()
.setVisible(WindowInsetsCompat.Type.ime(), isImeVisible)
.setInsets(WindowInsetsCompat.Type.ime(), if (isImeVisible) Insets.of(0, 0, 0, 300) else Insets.NONE)
.build()
)
}
waitForIdle()
return sheetState
}
@@ -95,6 +95,24 @@ class TimelineProtectionPresenterTest {
}
}
@Test
fun `present - shown content is restored when the presenter is recreated`() = runTest {
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Off, hideInviteAvatar = false)
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
val timelineProtectionStore = DefaultTimelineProtectionStore()
createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test {
val initialState = awaitItem()
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
}
createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test {
val initialState = awaitItem()
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - validate media scans the media source`() = runTest {
@@ -134,9 +152,11 @@ class TimelineProtectionPresenterTest {
room: BaseRoom = FakeBaseRoom(),
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
contentScannerService: ContentScannerService = ContentScannerService { _: List<MediaSource>, _: ContentValidationState -> },
timelineProtectionStore: TimelineProtectionStore = DefaultTimelineProtectionStore(),
) = TimelineProtectionPresenter(
mediaPreviewService = mediaPreviewService,
room = room,
contentScannerService = contentScannerService,
timelineProtectionStore = timelineProtectionStore,
)
}
@@ -74,8 +74,14 @@ class PreferencesRootPresenter(
val showLinkNewDevice by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
}.collectAsState(initial = false)
val isUserStatusEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus).collectAsState(initial = false)
val userStatusState = if (isUserStatusEnabled) userStatusPresenter.present() else null
val isUserStatusSupported by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
.collect { isUserStatusEnabled ->
value = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
}
}
val userStatusState = if (isUserStatusSupported) userStatusPresenter.present() else null
val otherSessions by remember {
sessionStore.sessionsFlow().map { list ->
@@ -9,9 +9,12 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -25,7 +28,10 @@ import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.user.UserPreferences
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.UserStatusView
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@@ -71,71 +77,87 @@ fun PreferencesRootView(
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
// Include pref from other modules
PreferencePage(
modifier = modifier,
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_settings),
snackbarHost = { SnackbarHost(snackbarHostState) }
) {
UserPreferences(
modifier = Modifier.clickable {
onOpenUserProfile(state.myUser)
},
matrixUser = state.myUser,
)
if (state.isMultiAccountEnabled) {
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
Box(modifier = modifier) {
// Include pref from other modules
PreferencePage(
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_settings),
snackbarHost = { SnackbarHost(snackbarHostState) }
) {
UserPreferences(
modifier = Modifier.clickable {
onOpenUserProfile(state.myUser)
},
matrixUser = state.myUser,
)
}
if (state.userStatusState != null) {
UserStatusSection(
userStatusState = state.userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
showTopDivider = !state.isMultiAccountEnabled,
)
}
// 'Account' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onOpenAbout = onOpenAbout,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenLabs = onOpenLabs,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
if (state.isMultiAccountEnabled) {
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
)
}
)
if (state.userStatusState != null) {
UserStatusSection(
userStatusState = state.userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
showTopDivider = !state.isMultiAccountEnabled,
)
}
// 'Account' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onOpenAbout = onOpenAbout,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenLabs = onOpenLabs,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
}
)
}
state.userStatusState?.let {
UserStatusUpdateIndicator(it.updateStatusAction)
}
}
}
@Composable
private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction<Unit>) {
AsyncActionIndicator(
asyncAction = updateStatusAction,
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding(),
loading = { AsyncIndicator.Loading(text = stringResource(CommonStrings.common_saving)) },
failure = { _ -> AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed)) },
)
}
@Composable
private fun ColumnScope.UserStatusSection(
userStatusState: UserStatusState,
@@ -18,7 +18,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
import io.element.android.libraries.matrix.api.MatrixClient
@@ -38,6 +40,7 @@ class UserStatusPresenter(
var isEmojiPickerVisible by remember { mutableStateOf(false) }
val customTextFieldState = rememberTextFieldState()
val coroutineScope = rememberCoroutineScope()
val updateStatusAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvent(event: UserStatusEvent) {
when (event) {
@@ -47,11 +50,15 @@ class UserStatusPresenter(
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
is UserStatusEvent.SetStatus -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
coroutineScope.launch {
updateStatusAction.runUpdatingState { matrixClient.setUserStatus(event.status) }
}
}
UserStatusEvent.ClearStatus -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.clearUserStatus() }
coroutineScope.launch {
updateStatusAction.runUpdatingState { matrixClient.clearUserStatus() }
}
}
UserStatusEvent.OpenCustomInput -> {
val raw = userProfile.rawStatus
@@ -93,6 +100,7 @@ class UserStatusPresenter(
displayedStatus = userProfile.displayedStatus,
rawStatus = userProfile.rawStatus,
pickerState = effectivePickerState,
updateStatusAction = updateStatusAction.value,
eventSink = ::handleEvent,
)
}
@@ -11,6 +11,7 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Immutable
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
@@ -19,6 +20,7 @@ data class UserStatusState(
val displayedStatus: DisplayedStatus?,
val rawStatus: UserStatus?,
val pickerState: UserStatusPickerState,
val updateStatusAction: AsyncAction<Unit>,
val eventSink: (UserStatusEvent) -> Unit,
)
@@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
@@ -44,10 +45,12 @@ fun aUserStatusState(
displayedStatus: DisplayedStatus? = null,
rawStatus: UserStatus? = null,
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
updateStatusAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (UserStatusEvent) -> Unit = {},
) = UserStatusState(
displayedStatus = displayedStatus,
rawStatus = rawStatus,
pickerState = pickerState,
updateStatusAction = updateStatusAction,
eventSink = eventSink,
)
@@ -11,6 +11,12 @@
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Avatare in Chateinladungen ausblenden"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Medienvorschau im Nachrichtenverlauf ausblenden"</string>
<string name="screen_advanced_settings_labs">"Labs"</string>
<string name="screen_advanced_settings_live_location_section_description">"Die Entfernung, die du zurücklegen musst, um ein Update auszulösen."</string>
<string name="screen_advanced_settings_live_location_section_title">"Live-Standort-Updates"</string>
<plurals name="screen_advanced_settings_live_location_update_distance">
<item quantity="one">"Alle %1$d Meter"</item>
<item quantity="other">"Alle %1$d Meter"</item>
</plurals>
<string name="screen_advanced_settings_media_compression_description">"Lade Fotos und Videos schneller hoch und reduziere den Datenverbrauch"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimiere die Medienqualität"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderation und Sicherheit"</string>
@@ -101,6 +101,14 @@ Ha folytatja, egyes beállítások megváltozhatnak."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"rendszerbeállításokat"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"A rendszerértesítések ki vannak kapcsolva"</string>
<string name="screen_notification_settings_title">"Értesítések"</string>
<string name="screen_settings_user_status_away">"Távol"</string>
<string name="screen_settings_user_status_be_right_back">"Rögtön visszajövök"</string>
<string name="screen_settings_user_status_custom">"Egyedi…"</string>
<string name="screen_settings_user_status_custom_hint">"Egyéni állapot…"</string>
<string name="screen_settings_user_status_focus_time">"Feladatra koncentrál"</string>
<string name="screen_settings_user_status_in_a_meeting">"Értekezleten"</string>
<string name="screen_settings_user_status_on_the_road">"Úton"</string>
<string name="screen_settings_user_status_placeholder">"Mi az állapota?"</string>
<string name="theme_black">"Fekete"</string>
<string name="theme_dark">"Sötét"</string>
<string name="theme_light">"Világos"</string>
@@ -33,15 +33,15 @@
<string name="screen_advanced_settings_push_provider_android">"Dostawca powiadomień push"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Wyłącz edytor tekstu bogatego, aby pisać tekst Markdown ręcznie."</string>
<string name="screen_advanced_settings_send_read_receipts">"Potwierdzenia odczytania"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Gdy wyłączona, Twoje potwierdzenia odczytania nie zostaną wysłane. Potwierdzenia od innych wciąż będą odbierane."</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Po wyłączeniu potwierdzenia odczytu nie będą wysyłane. Nadal będziesz otrzymywać potwierdzenia od innych użytkowników."</string>
<string name="screen_advanced_settings_share_presence">"Udostępnij obecność"</string>
<string name="screen_advanced_settings_share_presence_description">"Gdy wyłączona, nie będziesz mógł wysyłać lub odbierać potwierdzeń odczytu ani powiadomień pisania."</string>
<string name="screen_advanced_settings_share_presence_description">"Po wyłączeniu nie będzie można wysyłać ani odbierać potwierdzeń odczytu i powiadomień o pisaniu."</string>
<string name="screen_advanced_settings_show_media_timeline_always_hide">"Zawsze ukrywaj"</string>
<string name="screen_advanced_settings_show_media_timeline_always_show">"Zawsze pokazuj"</string>
<string name="screen_advanced_settings_show_media_timeline_private_rooms">"W pokojach prywatnych"</string>
<string name="screen_advanced_settings_show_media_timeline_subtitle">"Ukryte media można zawsze wyświetlić, dotykając ich"</string>
<string name="screen_advanced_settings_show_media_timeline_title">"Pokaż media na osi czasu"</string>
<string name="screen_advanced_settings_view_source_description">"Włącz opcję, aby wyświetlić źródło wiadomości na osi czasu."</string>
<string name="screen_advanced_settings_view_source_description">"Włącz, aby móc wyświetlać źródło wiadomości na osi czasu."</string>
<string name="screen_blocked_users_empty">"Nie blokujesz żadnych użytkowników"</string>
<string name="screen_blocked_users_unblock_alert_action">"Odblokuj"</string>
<string name="screen_blocked_users_unblock_alert_description">"Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika."</string>
@@ -59,6 +59,7 @@
<string name="screen_labs_header_title">"Doriți experiențe noi?"</string>
<string name="screen_labs_title">"Laboratoare"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Setări adiționale"</string>
<string name="screen_notification_settings_call_ringtone_label">"Ton de apel"</string>
<string name="screen_notification_settings_calls_label">"Apeluri audio și video"</string>
<string name="screen_notification_settings_configuration_mismatch">"Nepotrivire de configurație"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Am simplificat Setările pentru notificări pentru a face opțiunile mai ușor de găsit.
@@ -79,14 +80,38 @@ Dacă continuați, unele dintre setările dumneavoastră pot fi modificate."</st
<string name="screen_notification_settings_invite_for_me_label">"Invitații"</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere."</string>
<string name="screen_notification_settings_mentions_section_title">"Mențiuni"</string>
<string name="screen_notification_settings_message_sound_dialog_choose_other">"Alegeți un alt sunet…"</string>
<string name="screen_notification_settings_message_sound_dialog_current_subtitle">"Se utilizează în prezent %1$s"</string>
<string name="screen_notification_settings_message_sound_dialog_title">"Sunet de mesaj"</string>
<string name="screen_notification_settings_message_sound_label">"Sunet de mesaj"</string>
<string name="screen_notification_settings_mode_all">"Toate"</string>
<string name="screen_notification_settings_mode_mentions">"Mențiuni"</string>
<string name="screen_notification_settings_notification_section_title">"Anunță-mă pentru"</string>
<string name="screen_notification_settings_room_mention_label">"Anunțați-mă pentru @room"</string>
<string name="screen_notification_settings_sound_custom_fallback">"Personalizat"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"Sunet personalizat…"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"Eroare la ștergerea fișierului"</string>
<string name="screen_notification_settings_sound_element_default">"Element Implicit"</string>
<string name="screen_notification_settings_sound_element_fade">"Element Fade"</string>
<string name="screen_notification_settings_sound_import_sound_error_title">"Eroare la importul fișierului"</string>
<string name="screen_notification_settings_sound_preview_sound_error_title">"A fost intâmpinată o problemă la previzualizarea sunetului de alertă"</string>
<string name="screen_notification_settings_sound_section_title">"Sunet"</string>
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"Ignorați eroarea privind sunetul de alertă"</string>
<string name="screen_notification_settings_sound_set_sound_error_title">"A fost întâmpinată o problemă la setarea sunetului de alertă"</string>
<string name="screen_notification_settings_sound_silent">"Silențios"</string>
<string name="screen_notification_settings_sound_system_default">"Implicit sistem"</string>
<string name="screen_notification_settings_system_notifications_action_required">"Pentru a primi notificări, vă rugăm să vă schimbați %1$s."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"Setări de sistem"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Notificările de sistem sunt dezactivate"</string>
<string name="screen_notification_settings_title">"Notificări"</string>
<string name="screen_settings_user_status_away">"Plecat"</string>
<string name="screen_settings_user_status_be_right_back">"Revin imediat"</string>
<string name="screen_settings_user_status_custom">"Personalizat…"</string>
<string name="screen_settings_user_status_custom_hint">"Status personalizat…"</string>
<string name="screen_settings_user_status_focus_time">"Timp de concentrare"</string>
<string name="screen_settings_user_status_in_a_meeting">"Într-o ședință"</string>
<string name="screen_settings_user_status_on_the_road">"Pe drum"</string>
<string name="screen_settings_user_status_placeholder">"Care este statusul dumneavoastră?"</string>
<string name="theme_black">"Negru"</string>
<string name="theme_dark">"Întunecat"</string>
<string name="theme_light">"Deschis"</string>
@@ -11,6 +11,13 @@
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"在聊天室邀請請求中隱藏大頭貼"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"在時間軸中隱藏媒體預覽"</string>
<string name="screen_advanced_settings_labs">"實驗室"</string>
<string name="screen_advanced_settings_live_location_section_description">"觸發更新所需的移動距離。"</string>
<string name="screen_advanced_settings_live_location_section_footer">"請確保已為此應用程式啟用「精確位置」功能。若要變更此權限,請前往 %1$s。"</string>
<string name="screen_advanced_settings_live_location_section_footer_link">"應用程式設定"</string>
<string name="screen_advanced_settings_live_location_section_title">"即時位置更新"</string>
<plurals name="screen_advanced_settings_live_location_update_distance">
<item quantity="other">"每 %1$d 公尺"</item>
</plurals>
<string name="screen_advanced_settings_media_compression_description">"上傳照片與影片更快且減少資料使用量"</string>
<string name="screen_advanced_settings_media_compression_title">"最佳化媒體品質"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"管理與安全"</string>
@@ -50,6 +57,7 @@
<string name="screen_labs_header_title">"想不想來點新花樣?"</string>
<string name="screen_labs_title">"實驗室"</string>
<string name="screen_notification_settings_additional_settings_section_title">"其他設定"</string>
<string name="screen_notification_settings_call_ringtone_label">"通話鈴聲"</string>
<string name="screen_notification_settings_calls_label">"音訊與視訊通話"</string>
<string name="screen_notification_settings_configuration_mismatch">"組態錯誤"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"我們簡化了通知設定,讓選項更容易搜尋。您過去選擇的某些自訂設定將不會在此顯示,但它們仍有作用。
@@ -68,14 +76,39 @@
<string name="screen_notification_settings_invite_for_me_label">"邀請"</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"您的家伺服器在加密聊天室中不支援此選項,可能無法收到部份聊天室的通知。"</string>
<string name="screen_notification_settings_mentions_section_title">"提及"</string>
<string name="screen_notification_settings_message_sound_dialog_choose_other">"選擇其他音效⋯⋯"</string>
<string name="screen_notification_settings_message_sound_dialog_current_subtitle">"目前正在使用 %1$s"</string>
<string name="screen_notification_settings_message_sound_dialog_title">"訊息音效"</string>
<string name="screen_notification_settings_message_sound_label">"訊息音效"</string>
<string name="screen_notification_settings_mode_all">"全部"</string>
<string name="screen_notification_settings_mode_mentions">"提及"</string>
<string name="screen_notification_settings_notification_section_title">"通知我"</string>
<string name="screen_notification_settings_room_mention_label">"於 @room 通知我"</string>
<string name="screen_notification_settings_sound_custom_fallback">"自訂"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"自訂音效⋯⋯"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"刪除檔案時發生錯誤"</string>
<string name="screen_notification_settings_sound_element_default">"Element 預設值"</string>
<string name="screen_notification_settings_sound_element_fade">"Element Fade"</string>
<string name="screen_notification_settings_sound_import_sound_error_title">"匯入檔案時發生錯誤"</string>
<string name="screen_notification_settings_sound_preview_sound_error_title">"預覽警示音時發生問題"</string>
<string name="screen_notification_settings_sound_section_title">"音效"</string>
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"忽略警示音錯誤"</string>
<string name="screen_notification_settings_sound_set_sound_error_title">"設定警示音時發生問題"</string>
<string name="screen_notification_settings_sound_silent">"靜音"</string>
<string name="screen_notification_settings_sound_system_default">"系統預設值"</string>
<string name="screen_notification_settings_system_notifications_action_required">"要收到通知,請變更您的 %1$s。"</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"系統設定"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"已關閉系統通知"</string>
<string name="screen_notification_settings_title">"通知"</string>
<string name="screen_settings_user_status_away">"離開"</string>
<string name="screen_settings_user_status_be_right_back">"稍後回來"</string>
<string name="screen_settings_user_status_custom">"自訂⋯⋯"</string>
<string name="screen_settings_user_status_custom_hint">"自訂狀態⋯⋯"</string>
<string name="screen_settings_user_status_focus_time">"專注時間"</string>
<string name="screen_settings_user_status_in_a_meeting">"在會議中"</string>
<string name="screen_settings_user_status_on_the_road">"在路上"</string>
<string name="screen_settings_user_status_placeholder">"您的狀態如何?"</string>
<string name="theme_black">"黑色"</string>
<string name="theme_dark">"深色"</string>
<string name="theme_light">"淺色"</string>
<string name="theme_system">"系統"</string>
@@ -8,9 +8,11 @@
package io.element.android.features.preferences.impl.userstatus
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
@@ -61,6 +63,24 @@ class UserStatusPresenterTest {
val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(client.setUserStatusCalled).isTrue()
}
}
@Test
fun `Set event failure surfaces a Failure action`() = runTest {
val client = FakeMatrixClient().apply {
setUserStatusResult = Result.failure(AN_EXCEPTION)
}
createPresenter(client).test {
val status = UserStatus(emoji = "💬", text = "In a meeting")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
// On failure the SDK does not emit a new profile, so nothing is displayed.
assertThat(newState.displayedStatus).isNull()
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(client.setUserStatusCalled).isTrue()
}
}
@@ -121,6 +141,25 @@ class UserStatusPresenterTest {
val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(clearedState.displayedStatus).isNull()
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(client.clearUserStatusCalled).isTrue()
}
}
@Test
fun `Clear event failure surfaces a Failure action`() = runTest {
val client = FakeMatrixClient().apply {
clearUserStatusResult = Result.failure(AN_EXCEPTION)
}
createPresenter(client).test {
val status = UserStatus(emoji = "☕", text = "Be right back")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
awaitItem().eventSink(UserStatusEvent.ClearStatus)
val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
// Clear failed, so the previously set status is still displayed.
assertThat(clearedState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(client.clearUserStatusCalled).isTrue()
}
}
@@ -8,6 +8,8 @@
<string name="screen_bug_report_editor_placeholder">"Descrieți problema…"</string>
<string name="screen_bug_report_editor_supporting">"Dacă posibil, vă rugăm să scrieți descrierea în engleză."</string>
<string name="screen_bug_report_error_description_too_short">"Descrierea este prea scurtă, vă rugăm să oferiți mai multe detalii despre ceea ce s-a întâmplat. Vă mulțumim!"</string>
<string name="screen_bug_report_github_issue_description">"Puteți introduce numărul unui tichet GitHub asociat, dacă există."</string>
<string name="screen_bug_report_github_issue_label">"Tichet GitHub"</string>
<string name="screen_bug_report_include_crash_logs">"Trimiteți log-uri"</string>
<string name="screen_bug_report_include_logs">"Permiteți log-uri"</string>
<string name="screen_bug_report_include_logs_error">"Jurnalele dumneavoastră sunt prea mari și nu pot fi incluse în acest raport. Vă rugăm să ni le trimiteți prin altă metodă."</string>
@@ -8,6 +8,8 @@
<string name="screen_bug_report_editor_placeholder">"描述問題…"</string>
<string name="screen_bug_report_editor_supporting">"如果方便的話,請使用英文。"</string>
<string name="screen_bug_report_error_description_too_short">"您的描述太短了,請提供更多細節。謝謝!"</string>
<string name="screen_bug_report_github_issue_description">"若有相關的 GitHub 議題編號,請在此處輸入。"</string>
<string name="screen_bug_report_github_issue_label">"GitHub 議題"</string>
<string name="screen_bug_report_include_crash_logs">"傳送當機紀錄"</string>
<string name="screen_bug_report_include_logs">"提供日誌"</string>
<string name="screen_bug_report_include_logs_error">"您的紀錄檔太大了,無法包含在此報告中,請透過其他方式傳送給我們。"</string>
@@ -6,6 +6,7 @@
<string name="screen_room_change_permissions_delete_messages">"移除訊息"</string>
<string name="screen_room_change_permissions_everyone">"成員"</string>
<string name="screen_room_change_permissions_invite_people">"邀請夥伴"</string>
<string name="screen_room_change_permissions_live_location">"分享即時位置"</string>
<string name="screen_room_change_permissions_manage_space">"管理空間"</string>
<string name="screen_room_change_permissions_manage_space_rooms">"管理聊天室"</string>
<string name="screen_room_change_permissions_member_moderation">"管理成員"</string>
@@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
@@ -58,6 +59,7 @@ class RoomMemberListPresenter(
override fun present(): RoomMemberListState {
val searchQuery = rememberTextFieldState()
val membersState by room.membersStateFlow.collectAsState()
val roomInfo by room.roomInfoFlow.collectAsState()
val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() }
val roomModerationState = roomMembersModerationPresenter.present()
@@ -72,13 +74,18 @@ class RoomMemberListPresenter(
var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS) }
var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
var filteredRoomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
val activeCallParticipants = roomInfo.activeRoomCallParticipants
val comparator = remember(activeCallParticipants) {
roomMemberListComparator(activeCallParticipants)
}
// Update the room members when the screen is loaded
LaunchedEffect(Unit) {
room.updateMembers()
}
LaunchedEffect(membersState, roomMemberIdentityStates) {
LaunchedEffect(membersState, roomMemberIdentityStates, activeCallParticipants) {
if (membersState is RoomMembersState.Unknown) {
return@LaunchedEffect
}
@@ -90,23 +97,23 @@ class RoomMemberListPresenter(
}
withContext(coroutineDispatchers.io) {
val members = membersState.roomMembers().orEmpty().groupBy { it.membership }
val info = room.info()
if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < info.joinedMembersCount / 2) {
if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < roomInfo.joinedMembersCount / 2) {
// Don't display initial room member list if we have less than half of the joined members:
// This result will come from the timeline loading membership events and it'll be wrong.
// This result will come from the timeline loading membership events, and it'll be wrong.
return@withContext
}
val result = RoomMembers(
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList())
.map { it.withIdentityState(roomMemberIdentityStates) }
.map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) }
.toImmutableList(),
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(powerLevelRoomMemberComparator)
.map { it.withIdentityState(roomMemberIdentityStates) }
.sortedWith(comparator)
// .sortedWith(powerLevelRoomMemberComparator)
.map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) }
.toImmutableList(),
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList())
.sortedBy { it.userId.value }
.map { it.withIdentityState(roomMemberIdentityStates) }
.map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) }
.toImmutableList(),
)
roomMembers = if (membersState is RoomMembersState.Pending) {
@@ -150,12 +157,29 @@ class RoomMemberListPresenter(
return state
}
private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState {
private suspend fun RoomMember.withIdentityState(
identityStates: ImmutableMap<UserId, IdentityState>,
activeCallParticipants: ImmutableList<UserId>
): RoomMemberWithIdentityState {
return if (room.info().isEncrypted != true) {
RoomMemberWithIdentityState(this, null)
RoomMemberWithIdentityState(this, null, userId in activeCallParticipants)
} else {
val identityState = identityStates[userId] ?: encryptionService.getUserIdentity(userId, fallbackToServer = false).getOrNull()
RoomMemberWithIdentityState(this, identityState)
RoomMemberWithIdentityState(this, identityState, userId in activeCallParticipants)
}
}
private fun roomMemberListComparator(
activeCallParticipants: List<UserId>,
): Comparator<RoomMember> =
Comparator { a, b ->
val aInCall = a.userId in activeCallParticipants
val bInCall = b.userId in activeCallParticipants
when {
aInCall && !bInCall -> -1
!aInCall && bInCall -> 1
else -> powerLevelRoomMemberComparator.compare(a, b)
}
}
}
@@ -66,4 +66,5 @@ data class RoomMembers(
data class RoomMemberWithIdentityState(
val roomMember: RoomMember,
val identityState: IdentityState?,
val isInCall: Boolean
)
@@ -78,7 +78,7 @@ private fun aLoadedRoomMembers() = AsyncData.Success(
joined = persistentListOf(
anAlice().withIdentity(identityState = IdentityState.Verified),
aBob().withIdentity(identityState = IdentityState.PinViolation),
aCarol().withIdentity(),
aCarol().withIdentity(isInCall = true),
aDavid().withIdentity(),
anEve().withIdentity(identityState = IdentityState.VerificationViolation)
),
@@ -174,4 +174,8 @@ fun aBannedSusie(): RoomMember = aRoomMember(UserId("@susie:server.org"), USER_N
fun aBannedMallory(): RoomMember = aRoomMember(UserId("@mallory:server.org"), USER_NAME_MALLORY, membership = RoomMembershipState.BAN)
private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState)
private fun RoomMember.withIdentity(identityState: IdentityState? = null, isInCall: Boolean = false) = RoomMemberWithIdentityState(
this,
identityState,
isInCall
)
@@ -338,6 +338,15 @@ private fun RoomMemberListItem(
color = ElementTheme.colors.textSecondary,
)
}
if (roomMemberWithIdentity.isInCall) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = stringResource(CommonStrings.action_video_call),
tint = ElementTheme.colors.iconSuccessPrimary
)
}
}
}
)
@@ -191,6 +191,38 @@ class RoomMemberListPresenterTest {
awaitItem().eventSink(RoomMemberListEvent.RoomMemberSelected(anInvitedVictor()))
}
}
@Test
fun `call participants are shown first in joined members`() = runTest {
val room = createFakeJoinedRoom().apply {
givenRoomInfo(
aRoomInfo(
joinedMembersCount = 5,
activeRoomCallParticipants = listOf(
aCarol().userId
),
)
)
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
}
val presenter = createPresenter(joinedRoom = room)
presenter.test {
skipItems(1)
val loadedState = awaitItem()
val joined = loadedState.filteredRoomMembers.dataOrNull()!!.joined
assertThat(joined.first().roomMember.userId)
.isEqualTo(aCarol().userId)
assertThat(joined.first().isInCall).isTrue()
assertThat(joined.count { it.isInCall }).isEqualTo(1)
assertThat(joined.drop(1).all { !it.isInCall }).isTrue()
}
}
}
private fun createFakeJoinedRoom(
@@ -8,10 +8,11 @@
<string name="screen_chat_backup_key_storage_toggle_description">"Schlüssel von diesem Gerät hochladen"</string>
<string name="screen_chat_backup_key_storage_toggle_title">"Schlüsselspeicherung zulassen"</string>
<string name="screen_chat_backup_recovery_action_change">"Wiederherstellungsschlüssel ändern"</string>
<string name="screen_chat_backup_recovery_action_change_description">"Stelle deine kryptographische Identität und deinen Nachrichtenverlauf mit einem Wiederherstellungsschlüssel wieder her, falls du deine Geräte verloren hast."</string>
<string name="screen_chat_backup_recovery_action_change_description">"Von deinen Chats wird automatisch ein Ende-zu-Ende-verschlüsseltes Backup erstellt. Wenn du den Zugriff auf alle deine Geräte verlierst, brauchst du deinen Wiederherstellungsschlüssel, um dieses Backup wiederherzustellen und deine digitale Identität zu behalten."</string>
<string name="screen_chat_backup_recovery_action_confirm">"Wiederherstellungsschlüssel eingeben"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Dein Schlüssel ist derzeit nicht synchronisiert."</string>
<string name="screen_chat_backup_recovery_action_setup">"Wiederherstellungsschlüssel einrichten"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Von deinen Chats wird automatisch ein Ende-zu-Ende-verschlüsseltes Backup erstellt. Wenn du den Zugriff auf alle deine Geräte verlierst, brauchst du deinen Wiederherstellungsschlüssel, um dieses Backup wiederherzustellen und deine digitale Identität zu behalten."</string>
<string name="screen_create_new_recovery_key_list_item_1">
"Öffne "
<b>"%1$s"</b>
@@ -8,11 +8,11 @@
<string name="screen_chat_backup_key_storage_toggle_description">"Prześlij klucze z tego urządzenia"</string>
<string name="screen_chat_backup_key_storage_toggle_title">"Zezwól na magazynowanie kluczy"</string>
<string name="screen_chat_backup_recovery_action_change">"Zmień klucz przywracania"</string>
<string name="screen_chat_backup_recovery_action_change_description">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="screen_chat_backup_recovery_action_change_description">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="screen_chat_backup_recovery_action_confirm">"Wprowadź klucz przywracania"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Magazyn kluczy nie jest zsynchronizowany."</string>
<string name="screen_chat_backup_recovery_action_setup">"Uzyskaj klucz przywracania"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Otwórz %1$s na urządzeniu stacjonarnym"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Zaloguj się ponownie na swoje konto"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Gdy pojawi się prośba o weryfikację urządzenia, wybierz %1$s"</string>
@@ -8,11 +8,11 @@
<string name="screen_chat_backup_key_storage_toggle_description">"Încărcați cheile de pe acest dispozitiv"</string>
<string name="screen_chat_backup_key_storage_toggle_title">"Permiteți stocarea cheilor"</string>
<string name="screen_chat_backup_recovery_action_change">"Schimbați cheia de recuperare"</string>
<string name="screen_chat_backup_recovery_action_change_description">"Mesajele dumneavoastră sunt copiate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="screen_chat_backup_recovery_action_change_description">"Mesajele dumneavoastră sunt copiate automat cu criptare end-to-end. Pentru a restaura aceast backup și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="screen_chat_backup_recovery_action_confirm">"Introduceți cheia de recuperare"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Backup-ul pentru chat nu este sincronizat în prezent."</string>
<string name="screen_chat_backup_recovery_action_setup">"Obțineți cheia de recuperare"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această backup și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Deschideți %1$s pe un dispozitiv desktop"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Conectați-vă din nou la contul dumneavoastră"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Când vi se cere să vă verificați dispozitivul, selectați%1$s"</string>
@@ -20,6 +20,28 @@
<string name="screen_create_new_recovery_key_list_item_4">"Urmați instrucțiunile pentru a crea o nouă cheie de recuperare"</string>
<string name="screen_create_new_recovery_key_list_item_5">"Salvați noua cheie de recuperare într-un manager de parole sau o notă criptată"</string>
<string name="screen_create_new_recovery_key_title">"Resetați criptarea contului dumneavoastră folosind un alt dispozitiv"</string>
<string name="screen_custom_recovery_key_confirm_error_mismatch">"Cheia de recuperare introdusă nu se potrivește"</string>
<string name="screen_custom_recovery_key_confirm_submit">"Finalizați configurarea"</string>
<string name="screen_custom_recovery_key_confirm_subtitle">"Introduceți din nou cheia de recuperare."</string>
<string name="screen_custom_recovery_key_confirm_title">"Confirmați cheia de recuperare"</string>
<plurals name="screen_custom_recovery_key_input_notice">
<item quantity="one">"Minim %1$d caracter. Nu folosiți parola contului dvs."</item>
<item quantity="few">"Minim %1$d caractere. Nu folosiți parola contului dvs."</item>
<item quantity="other">"Minim %1$d caractere. Nu folosiți parola contului dvs."</item>
</plurals>
<string name="screen_custom_recovery_key_input_strength">"Rezistenţă"</string>
<string name="screen_custom_recovery_key_input_strength_a11y">"Rezistența parolei: %1$s"</string>
<string name="screen_custom_recovery_key_input_strength_moderate">"Moderată"</string>
<string name="screen_custom_recovery_key_input_strength_strong">"Puternică"</string>
<string name="screen_custom_recovery_key_input_strength_very_strong">"Foarte puternică"</string>
<string name="screen_custom_recovery_key_input_strength_very_weak">"Foarte slabă"</string>
<string name="screen_custom_recovery_key_input_strength_weak">"Slabă"</string>
<string name="screen_custom_recovery_key_input_subtitle">"Alegeți o cheie de recuperare pe care o puteți memora."</string>
<string name="screen_custom_recovery_key_input_title">"Introduceți o cheie de recuperare"</string>
<string name="screen_custom_recovery_key_loading_a11y">"Se încarcă cerințele cheii de recuperare"</string>
<string name="screen_custom_recovery_key_success_notice">"Pentru a schimba cheia de recuperare, accesați Setări → Criptare → Backup"</string>
<string name="screen_custom_recovery_key_success_subtitle">"Puteți folosi cheia de recuperare pentru a confirma dispozitive noi sau pentru a restaura chat-urile criptate dacă pierdeți accesul la toate dispozitivele."</string>
<string name="screen_custom_recovery_key_success_title">"Backup-ul dumneavoastră este acum complet configurat"</string>
<string name="screen_encryption_reset_action_continue_reset">"Continuați resetarea"</string>
<string name="screen_encryption_reset_bullet_1">"Detaliile contului, contactele, preferințele și lista de chat vor fi păstrate"</string>
<string name="screen_encryption_reset_bullet_2">"Veți pierde mesajele anterioare care au fost stocate doar pe server"</string>
@@ -20,6 +20,26 @@
<string name="screen_create_new_recovery_key_list_item_4">"按照說明建立新復原金鑰"</string>
<string name="screen_create_new_recovery_key_list_item_5">"將您的新復原金鑰儲存在密碼管理程式或加密筆記中"</string>
<string name="screen_create_new_recovery_key_title">"使用其他裝置重設您帳號的加密"</string>
<string name="screen_custom_recovery_key_confirm_error_mismatch">"您輸入的復原金鑰不符合"</string>
<string name="screen_custom_recovery_key_confirm_submit">"完成設定"</string>
<string name="screen_custom_recovery_key_confirm_subtitle">"請再次輸入您的復原金鑰。"</string>
<string name="screen_custom_recovery_key_confirm_title">"確認您的復原金鑰"</string>
<plurals name="screen_custom_recovery_key_input_notice">
<item quantity="other">"最少 %1$d 個字元。請勿使用您的帳號密碼。"</item>
</plurals>
<string name="screen_custom_recovery_key_input_strength">"強度"</string>
<string name="screen_custom_recovery_key_input_strength_a11y">"密碼短語強度:%1$s"</string>
<string name="screen_custom_recovery_key_input_strength_moderate">"中等"</string>
<string name="screen_custom_recovery_key_input_strength_strong">"強"</string>
<string name="screen_custom_recovery_key_input_strength_very_strong">"非常強"</string>
<string name="screen_custom_recovery_key_input_strength_very_weak">"非常弱"</string>
<string name="screen_custom_recovery_key_input_strength_weak">"弱"</string>
<string name="screen_custom_recovery_key_input_subtitle">"請輸入您可以記住的復原金鑰。"</string>
<string name="screen_custom_recovery_key_input_title">"輸入復原金鑰"</string>
<string name="screen_custom_recovery_key_loading_a11y">"正在載入復原金鑰需求"</string>
<string name="screen_custom_recovery_key_success_notice">"若要變更復原金鑰,請前往「設定」→「加密」→「備份」"</string>
<string name="screen_custom_recovery_key_success_subtitle">"若您無法存取您所有的裝置,可以使用復原金鑰來確認新裝置或還原加密聊天紀錄。"</string>
<string name="screen_custom_recovery_key_success_title">"您的備份已設定完成"</string>
<string name="screen_encryption_reset_action_continue_reset">"繼續重設"</string>
<string name="screen_encryption_reset_bullet_1">"您的帳號詳細資訊、聯絡人、偏好設定與聊天清單都會保留"</string>
<string name="screen_encryption_reset_bullet_2">"您將會遺失僅儲存在伺服器上的任何訊息歷史紀錄"</string>
@@ -5,7 +5,7 @@
<string name="screen_start_chat_error_starting_chat">"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"</string>
<string name="screen_start_chat_join_room_by_address_action">"Chat per Adresse beitreten"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Keine gültige Adresse"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Eintreten…"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Chatraum Adresse eingeben …"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Passender Chat gefunden"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Chat nicht gefunden"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"z. B. #room -name:matrix.org"</string>
@@ -5,7 +5,7 @@
<string name="screen_start_chat_error_starting_chat">"A apărut o eroare la încercarea începerii conversației"</string>
<string name="screen_start_chat_join_room_by_address_action">"Gasiți o cameră după adresă"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Adresa nu e este validă"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Introduceți…"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Introduceți adresa…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"S-a găsit o cameră"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Nu a putut fi găsită nici o cameră"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"de exemplu #nume-camera:matrix.org"</string>
@@ -5,7 +5,7 @@
<string name="screen_start_chat_error_starting_chat">"嘗試開始聊天時發生錯誤"</string>
<string name="screen_start_chat_join_room_by_address_action">"按地址加入聊天室"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"不是有效的位址"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"輸入……"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"輸入地址⋯⋯"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"找到相符的聊天室"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"找不到聊天室"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"例如 #room-name:matrix.org"</string>
@@ -11,16 +11,16 @@
<string name="screen_identity_use_another_device">"Użyj innego urządzenia"</string>
<string name="screen_identity_waiting_on_other_device">"Oczekiwanie na inne urządzenie…"</string>
<string name="screen_session_verification_cancelled_subtitle">"Coś tu nie gra. Albo upłynął limit czasu, albo żądanie zostało odrzucone."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Upewnij się, że emoji poniżej pasują do tych pokazanych na innej sesji."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Upewnij się, że zestaw emoji poniżej pasuje do tych na drugiej sesji."</string>
<string name="screen_session_verification_compare_emojis_title">"Porównaj emoji"</string>
<string name="screen_session_verification_compare_emojis_user_subtitle">"Upewnij się, że emoji poniżej odpowiadają tym na urządzeniu drugiego użytkownika."</string>
<string name="screen_session_verification_compare_numbers_subtitle">"Upewnij się, że liczby poniżej pasują do tych wyświetlanych na innej sesji."</string>
<string name="screen_session_verification_compare_numbers_subtitle">"Upewnij się, że liczby poniżej pasują do tych wyświetlanych na drugiej sesji."</string>
<string name="screen_session_verification_compare_numbers_title">"Porównaj liczby"</string>
<string name="screen_session_verification_complete_subtitle">"Teraz możesz bezpiecznie czytać i wysyłać wiadomości na swoim drugim urządzeniu."</string>
<string name="screen_session_verification_complete_user_subtitle">"Teraz możesz zaufać tożsamości tego użytkownika podczas wysyłania lub odbierania wiadomości."</string>
<string name="screen_session_verification_device_verified">"Urządzenie zweryfikowane"</string>
<string name="screen_session_verification_enter_recovery_key">"Wprowadź klucz przywracania"</string>
<string name="screen_session_verification_failed_subtitle">"Albo upłynął limit czasu żądania, albo żądanie zostało odrzucone, albo wystąpił błąd weryfikacji."</string>
<string name="screen_session_verification_failed_subtitle">"Upłynął limit czasu żądania, żądanie zostało odrzucone lub wystąpił błąd weryfikacji."</string>
<string name="screen_session_verification_open_existing_session_subtitle">"Udowodnij, że to ty, aby uzyskać dostęp do historii zaszyfrowanych wiadomości."</string>
<string name="screen_session_verification_open_existing_session_title">"Otwórz istniejącą sesję"</string>
<string name="screen_session_verification_positive_button_canceled">"Ponów weryfikację"</string>
@@ -29,9 +29,9 @@
<string name="screen_session_verification_ready_subtitle">"Porównaj unikalny zestaw emoji."</string>
<string name="screen_session_verification_request_accepted_subtitle">"Porównaj unikalny zestaw emoji i upewnij się, że są w tej samej kolejności."</string>
<string name="screen_session_verification_request_details_timestamp">"Zalogowano"</string>
<string name="screen_session_verification_request_failure_subtitle">"Albo upłynął limit czasu żądania, albo żądanie zostało odrzucone, albo wystąpił błąd weryfikacji."</string>
<string name="screen_session_verification_request_failure_subtitle">"Upłynął limit czasu żądania, żądanie zostało odrzucone lub wystąpił błąd weryfikacji."</string>
<string name="screen_session_verification_request_failure_title">"Weryfikacja nie powiodła się"</string>
<string name="screen_session_verification_request_footer">"Kontynuuj tylko, jeśli to Ty zainicjowałeś tę weryfikację."</string>
<string name="screen_session_verification_request_footer">"Kontynuuj tylko jeśli to Ty zainicjowałeś proces weryfikacji."</string>
<string name="screen_session_verification_request_subtitle">"Zweryfikuj drugie urządzenie, aby zabezpieczyć historię wiadomości."</string>
<string name="screen_session_verification_request_success_subtitle">"Teraz możesz bezpiecznie czytać i wysyłać wiadomości na swoim drugim urządzeniu."</string>
<string name="screen_session_verification_request_success_title">"Urządzenie zweryfikowane"</string>
+1 -1
View File
@@ -270,7 +270,7 @@ metro = { id = "dev.zacsweers.metro", version.ref = "metro" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
ktlint = "org.jlleitschuh.gradle.ktlint:14.2.0"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
dependencycheck = "org.owasp.dependencycheck:12.2.2"
dependencycheck = "org.owasp.dependencycheck:13.0.0"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
paparazzi = "app.cash.paparazzi:2.0.0-alpha05"
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
@@ -13,3 +13,5 @@ import java.nio.charset.Charset
fun String.urlEncoded(charset: Charset = Charsets.UTF_8): String = URLEncoder.encode(this, charset.name())
fun String.urlDecoded(charset: Charset = Charsets.UTF_8): String = URLDecoder.decode(this, charset.name())
fun String.takeIfNotBlank(): String? = ifBlank { null }
@@ -0,0 +1,114 @@
/*
* 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.async
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.element.android.libraries.architecture.AsyncAction
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource
private val DefaultShowDelay = 300.milliseconds
private val DefaultMinDisplayDuration = 500.milliseconds
private val DefaultFailureDuration = 500.milliseconds
/**
* An [AsyncIndicatorHost] driven by an [AsyncAction], with anti-flicker timing.
*
* While [asyncAction] is [AsyncAction.Loading], [loading] is displayed, but only if the operation
* runs longer than [showDelay]; once shown it stays visible for at least [minDisplayDuration]. This
* prevents the indicator from flashing for operations that complete almost instantly.
*
* When [asyncAction] is [AsyncAction.Failure], [failure] is displayed immediately (failures are
* never debounced) and auto-dismisses after [failureDuration].
*
* @param asyncAction the action whose progress drives the indicator.
* @param loading the composable to display while loading.
* @param failure the composable to display on failure.
* @param modifier the modifier to apply to the underlying [AsyncIndicatorHost].
* @param state the [AsyncIndicatorState] backing the host.
* @param showDelay how long the loading state must persist before the indicator is shown.
* @param minDisplayDuration the minimum duration the loading indicator stays visible once shown.
* @param failureDuration how long the failure indicator stays visible before auto-dismissing.
*/
@Composable
fun AsyncActionIndicator(
asyncAction: AsyncAction<*>,
loading: @Composable () -> Unit,
failure: @Composable (Throwable) -> Unit,
modifier: Modifier = Modifier,
state: AsyncIndicatorState = rememberAsyncIndicatorState(),
showDelay: Duration = DefaultShowDelay,
minDisplayDuration: Duration = DefaultMinDisplayDuration,
failureDuration: Duration = DefaultFailureDuration,
) {
AsyncIndicatorHost(modifier = modifier, state = state)
AsyncActionIndicatorEffect(
asyncAction = asyncAction,
state = state,
showDelay = showDelay,
minDisplayDuration = minDisplayDuration,
failureDuration = failureDuration,
loading = loading,
failure = failure,
)
}
@Composable
internal fun AsyncActionIndicatorEffect(
asyncAction: AsyncAction<*>,
state: AsyncIndicatorState,
showDelay: Duration,
minDisplayDuration: Duration,
failureDuration: Duration,
loading: @Composable () -> Unit,
failure: @Composable (Throwable) -> Unit,
timeSource: TimeSource = TimeSource.Monotonic,
) {
// Referenced inside the restarting LaunchedEffect below, so keep them up to date.
val currentLoading by rememberUpdatedState(loading)
val currentFailure by rememberUpdatedState(failure)
// Mark set when the loader actually becomes visible, null while it is hidden.
var loaderShownAt by remember { mutableStateOf<TimeMark?>(null) }
LaunchedEffect(asyncAction) {
when (asyncAction) {
is AsyncAction.Loading -> {
// If the operation completes before this delay elapses, the effect is cancelled
// and the loader is never shown.
delay(showDelay)
state.enqueue(composable = currentLoading)
loaderShownAt = timeSource.markNow()
}
is AsyncAction.Failure -> {
// Always surface failures immediately, even if the loader was never shown.
val error = asyncAction.error
state.enqueue(durationMs = failureDuration.inWholeMilliseconds, composable = { currentFailure(error) })
loaderShownAt = null
}
is AsyncAction.Success -> {
// Hold the loader for its minimum duration before clearing it.
loaderShownAt?.let { shownAt ->
val remaining = minDisplayDuration - shownAt.elapsedNow()
if (remaining > Duration.ZERO) delay(remaining)
}
state.clear()
loaderShownAt = null
}
else -> Unit
}
}
}
@@ -0,0 +1,123 @@
/*
* 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.component.async
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicatorEffect
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TestTimeSource
import kotlin.time.TimeSource
@OptIn(ExperimentalCoroutinesApi::class)
class AsyncActionIndicatorTest {
private val showDelay = 300.milliseconds
private val minDisplay = 500.milliseconds
@Test
fun `loader is not shown before the show delay elapses`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Loading
advanceTimeBy(100.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
// Once the show delay has fully elapsed, the loader is shown.
advanceTimeBy(300.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `loader is never shown when the operation completes before the show delay`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Loading
advanceTimeBy(100.milliseconds)
runCurrent()
action.value = AsyncAction.Success(Unit)
advanceTimeBy(1000.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `failure is shown immediately, without waiting for the show delay`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Failure(Exception("failed"))
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `loader stays visible for the minimum duration once shown`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
val timeSource = TestTimeSource()
indicatorFlow(action, state, timeSource).test {
// Show the loader.
action.value = AsyncAction.Loading
advanceTimeBy(400.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
// 200ms of display has already elapsed when the operation succeeds.
timeSource += 200.milliseconds
action.value = AsyncAction.Success(Unit)
runCurrent()
// Still within the minimum display duration (200ms shown, 300ms remaining) -> stays visible.
advanceTimeBy(200.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
// Past the minimum display duration -> cleared.
advanceTimeBy(200.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
private fun indicatorFlow(
action: State<AsyncAction<*>>,
state: AsyncIndicatorState,
timeSource: TimeSource = TimeSource.Monotonic,
): Flow<Boolean> = moleculeFlow(RecompositionMode.Immediate) {
AsyncActionIndicatorEffect(
asyncAction = action.value,
state = state,
showDelay = showDelay,
minDisplayDuration = minDisplay,
failureDuration = minDisplay,
loading = {},
failure = {},
timeSource = timeSource,
)
state.currentAnimationState.targetState
}
}
+1 -1
View File
@@ -36,5 +36,5 @@ dependencies {
implementation(libs.matrix.emojibase.bindings)
testImplementation(projects.libraries.matrix.test)
testCommonDependencies(libs)
testCommonDependencies(libs, includeTestComposeView = true)
}
@@ -62,7 +62,7 @@ internal fun EmojiPickerView(
Column(modifier) {
SearchBar(
modifier = Modifier.padding(bottom = 10.dp),
modifier = Modifier.padding(bottom = if (state.isSearchActive) 0.dp else 10.dp),
queryState = state.searchQuery,
resultState = state.searchResults,
active = state.isSearchActive,
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.libraries.emoji.impl.picker
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsEqualTo
import androidx.compose.ui.test.getBoundsInRoot
import androidx.compose.ui.test.hasScrollToIndexAction
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.compose.ui.unit.dp
import io.element.android.emojibasebindings.Emoji
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import org.junit.Test
import org.robolectric.annotation.Config
private val PICKER_HEIGHT = 600.dp
class EmojiPickerViewTest : RobolectricTest() {
@Config(qualifiers = "w400dp-h800dp")
@Test
fun `the search results reach the bottom of the picker`() = runAndroidComposeUiTest<ComponentActivity> {
setEmojiPickerView()
onNode(hasScrollToIndexAction())
.getBoundsInRoot()
.bottom
.assertIsEqualTo(PICKER_HEIGHT, "emoji grid bottom")
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setEmojiPickerView() {
setContent {
Box(Modifier.size(400.dp, PICKER_HEIGHT)) {
EmojiPickerView(
state = aDefaultEmojiPickerState(
isSearchActive = true,
searchQuery = "smile",
searchResults = SearchBarResultState.Results(
persistentListOf(
Emoji(
hexcode = "0x00",
label = "grinning face",
tags = persistentListOf("grinning"),
shortcodes = persistentListOf("smile"),
unicode = "😀",
skins = null,
),
)
),
),
onSelectEmoji = {},
selectedEmojis = persistentSetOf(),
modifier = Modifier.fillMaxSize(),
)
}
}
}
@@ -54,7 +54,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.util.Optional
interface MatrixClient {
interface MatrixClient : UrlContentFetcher {
val sessionId: SessionId
val deviceId: DeviceId
@@ -99,6 +99,12 @@ interface MatrixClient {
/** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */
suspend fun clearUserStatus(): Result<Unit>
/** Whether the homeserver advertises support for user status (MSC4426). */
suspend fun isUserStatusSupported(): Result<Boolean>
/** Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call. */
fun enableAutomaticCallStatus(enabled: Boolean)
suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?>
suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?>
suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?>
@@ -167,11 +173,6 @@ interface MatrixClient {
*/
fun userIdServerName(): String
/**
* Execute generic GET requests through the SDKs internal HTTP client.
*/
suspend fun getUrl(url: String): Result<ByteArray>
/**
* Get a room preview for a given room ID or alias. This is especially useful for rooms that the user is not a member of, or hasn't joined yet.
*/
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api
import java.lang.AutoCloseable
/**
* A Matrix client in an 'unauthenticated' state that can still be used to resolve URLs.
*
* Note: this client implements an [AutoCloseable] interface, so using [AutoCloseable.close] or [AutoCloseable.use] will clean up any resources associated
* with it, such as temporary files.
*/
interface TemporaryMatrixClient : UrlContentFetcher, AutoCloseable
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api
/**
* Factory to create a temporary [TemporaryMatrixClient] for a given home server URL.
*/
interface TemporaryMatrixClientFactory {
suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient>
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api
/**
* An interface to fetch content from a URL using a simple GET request.
*/
fun interface UrlContentFetcher {
/**
* Execute generic GET requests through the SDKs internal HTTP client.
*/
suspend fun getUrl(url: String): Result<ByteArray>
}

Some files were not shown because too many files have changed in this diff Show More