From d223d6ac7ddd0defd7d06fe2a6c97176ed556450 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:31:04 +0000 Subject: [PATCH 01/33] Update dependency org.jetbrains.kotlinx:kover-gradle-plugin to v0.9.11 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9b71e6bc96..b0445c54a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -62,7 +62,7 @@ detekt_compose_rules = "0.4.28" # See https://github.com/pinterest/ktlint/releases/ ktlint = "1.8.0" androidx-test-ext-junit = "1.3.0" -kover = "0.9.9" +kover = "0.9.11" # The native MatrixRTC call component, https://github.com/element-hq/element-call-android element_call = "0.1.0-rc.6" From 87e1deaccf49f394a18de4535dc99f6aca9ff56c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 09:56:57 +0200 Subject: [PATCH 02/33] Show account info on the identity confirmation screen Render an AccountInfoCard with the current user on the "Confirm your digital identity" screen, so the user knows which account needs to be verified. Closes #7898 Co-Authored-By: Claude Opus 5.5 (1M context) --- features/ftue/impl/build.gradle.kts | 1 + .../ChooseSelfVerificationModePresenter.kt | 4 ++ .../ChooseSelfVerificationModeState.kt | 2 + ...seSelfVerificationModeStatePreviewParam.kt | 4 ++ .../ChooseSelfVerificationModeView.kt | 40 +++++++++++++------ ...oseSessionVerificationModePresenterTest.kt | 28 +++++++++++++ 6 files changed, 67 insertions(+), 12 deletions(-) diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts index f6d1970a5a..1ef41bcae2 100644 --- a/features/ftue/impl/build.gradle.kts +++ b/features/ftue/impl/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiCommon) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt index f19c323d52..7fd2770fb3 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt @@ -20,16 +20,19 @@ import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState +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 @Inject class ChooseSelfVerificationModePresenter( + private val matrixClient: MatrixClient, private val encryptionService: EncryptionService, private val directLogoutPresenter: Presenter, ) : Presenter { @Composable override fun present(): ChooseSelfVerificationModeState { + val currentUser by matrixClient.userProfile.collectAsState() val hasDevicesToVerifyAgainst by encryptionService.hasDevicesToVerifyAgainst.collectAsState() val canUseRecoveryKey by produceState>(AsyncData.Uninitialized) { encryptionService.recoveryStateStateFlow @@ -72,6 +75,7 @@ class ChooseSelfVerificationModePresenter( } return ChooseSelfVerificationModeState( + currentUser = currentUser, buttonsState = buttonsState, directLogoutState = directLogoutState, eventSink = ::handleEvent, diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt index 0512636fca..98dbc340e4 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt @@ -10,8 +10,10 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.user.MatrixUser data class ChooseSelfVerificationModeState( + val currentUser: MatrixUser, val buttonsState: AsyncData, val directLogoutState: DirectLogoutState, val eventSink: (ChooseSelfVerificationModeEvent) -> Unit, diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt index 1af6786289..d12a3003ae 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt @@ -11,6 +11,8 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser class ChooseSelfVerificationModeStatePreviewParam : PreviewParameterProvider { @@ -43,7 +45,9 @@ class ChooseSelfVerificationModeStatePreviewParam : fun aChooseSelfVerificationModeState( buttonsState: AsyncData = AsyncData.Success(aButtonsState()), + currentUser: MatrixUser = aMatrixUser(), ) = ChooseSelfVerificationModeState( + currentUser = currentUser, buttonsState = buttonsState, directLogoutState = aDirectLogoutState(), eventSink = {}, diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt index f6f1094d1b..63d653410f 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt @@ -12,6 +12,7 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -39,6 +40,9 @@ import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState import io.element.android.libraries.ui.strings.CommonStrings @Composable @@ -75,6 +79,7 @@ fun ChooseSelfVerificationModeView( subTitle = stringResource(id = R.string.screen_identity_confirmation_subtitle) ) }, + isScrollable = true, footer = { ChooseSelfVerificationModeButtons( state = state, @@ -84,20 +89,31 @@ fun ChooseSelfVerificationModeView( ) } ) { - Row( + Column( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(24.dp), ) { - Text( - modifier = Modifier - .clickable(onClick = onLearnMore) - .padding(vertical = 4.dp, horizontal = 16.dp) - .semantics { - // Note: there is no Role.Link, so we use Role.Button for better accessibility support - role = Role.Button - }, - text = stringResource(CommonStrings.action_learn_more), - style = ElementTheme.typography.fontBodyLgMedium + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier + .clickable(onClick = onLearnMore) + .padding(vertical = 4.dp, horizontal = 16.dp) + .semantics { + // Note: there is no Role.Link, so we use Role.Button for better accessibility support + role = Role.Button + }, + text = stringResource(CommonStrings.action_learn_more), + style = ElementTheme.typography.fontBodyLgMedium + ) + } + AccountInfoCard( + state = AccountInfoCardState( + matrixUser = state.currentUser, + mode = AccountInfoCardMode.Simple, + ), ) } } diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt index 8ceb683993..78f257daf0 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt @@ -14,7 +14,13 @@ import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -34,6 +40,26 @@ class ChooseSessionVerificationModePresenterTest { } } + @Test + fun `present - current user is exposed`() = runTest { + val presenter = createPresenter( + matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, + userDisplayName = A_USER_NAME, + userAvatarUrl = AN_AVATAR_URL, + ), + ) + presenter.test { + assertThat(awaitItem().currentUser).isEqualTo( + MatrixUser( + userId = A_SESSION_ID, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL, + ) + ) + } + } + @Test fun `present - state is relayed from EncryptionService, order 1`() = runTest { val encryptionService = FakeEncryptionService() @@ -126,9 +152,11 @@ class ChooseSessionVerificationModePresenterTest { } private fun createPresenter( + matrixClient: MatrixClient = FakeMatrixClient(), encryptionService: FakeEncryptionService = FakeEncryptionService(), directLogoutPresenter: Presenter = Presenter { aDirectLogoutState() } ) = ChooseSelfVerificationModePresenter( + matrixClient = matrixClient, encryptionService = encryptionService, directLogoutPresenter = directLogoutPresenter, ) From 7ffb36fe4b3fe9b042d2cfe46ec635ef6e4c3587 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 12 Oct 2026 08:13:03 +0000 Subject: [PATCH 03/33] Update screenshots --- ...ion.choosemode_ChooseSelfVerificationModeView_Day_0_en.png | 4 ++-- ...ion.choosemode_ChooseSelfVerificationModeView_Day_1_en.png | 4 ++-- ...ion.choosemode_ChooseSelfVerificationModeView_Day_2_en.png | 4 ++-- ...ion.choosemode_ChooseSelfVerificationModeView_Day_3_en.png | 4 ++-- ...ion.choosemode_ChooseSelfVerificationModeView_Day_4_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_0_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_1_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_2_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_3_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_4_en.png | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png index a7d3c7fbf1..12adbd61f4 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:89fa873be57a757cf75383f982407025c6709fb12bf3adce35e09b4801e8ab72 -size 33146 +oid sha256:6daccb2b17eca0c0720ae731f38f82710839d5c744c9d1a8d60b70e6da41c82a +size 39472 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png index 7048ba3cfa..8e421af5ee 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d3e6d3204236f994ed9865eb7f5dec9000b34a3eda7cb0bd85d075a260b5c990 -size 28413 +oid sha256:25b6ab1e24e67a122eebf2e02d48412a349b85b9b8f2e742b7f1bca5c1146175 +size 34664 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png index 12289bc905..7be0af4b93 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4655a0726c3c763baed203a7d4fdab652dbf87150190b53319d201a5b5279880 -size 37800 +oid sha256:bbb89e5933828be05e39fa7bcebee6eeb34459913e4b999103fd04190184e405 +size 44172 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png index afc0332791..36a948c665 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fce9c2511b6f92bc70db299f6a3b76afd2cebf8c7cea5dfaa83f13119b642370 -size 33265 +oid sha256:fb092bee675333a26b9f2a63bc947c029356808caa65a1f77b317f3b42dc4b49 +size 39596 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png index f172017b45..2052266989 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:88daf59cbe7785f4d70c4e6416382efbabd53129c5ae23997ef3b636fc301143 -size 27053 +oid sha256:877604b659b8fa76febe1afb2509bc75d61d665b5f7fdc4bfc863db5e18edcb4 +size 33366 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png index d8f1c03f96..e3957acf4d 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cfd5fd8b652fd75e8d41d6b4152ff8fa539ecc9d4b7d4e16c6fff02b26a65594 -size 32101 +oid sha256:819c5f83d31fe66e64662e0f78f73b46ec0405ac3ef0058e688bc9bda09abfc2 +size 38430 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png index a491f58cea..a49f6804b1 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e14f0a6effa9c8ea2819347db478634d5142a4dbd7aa5a4445e77c71815cd4ea -size 27628 +oid sha256:9079a774c1acb8d925591bd060be5f90e7f7176ada04163507fcaeb508697c8e +size 33802 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png index 6cb91d8b3b..ce925ec765 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:efc658d0af41513ba7fec310f908bda4e552a19fb435817928362bf6814d5d8a -size 36586 +oid sha256:c14e2c3308774dd97e5d8e793c53836925d6bd61e27dfe6a0192f6fe2514086c +size 42902 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png index e657bc15ca..4dd0b016ae 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e1ae34e30535c8f313a166d3a8f70f67a63fb7c1b6f8e6a7dcf7c3721861d9d4 -size 32139 +oid sha256:29e50d5d09399a001a88199add0a5ff6974d546d16a127aa699862dd1535de59 +size 38457 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png index 8d2548a01d..8e90d03e64 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6fe07ea37942541f24e5ad3282ea75f85536a8bdf5c02cee0cbbe88b5fe4c30f -size 26268 +oid sha256:679496d2ce865f83726406b3c3e7c831e48b08e3e42972eb608a5c339319179e +size 32459 From 89123414396b5b555950f44edc4f7a956c840b49 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 10:42:19 +0200 Subject: [PATCH 04/33] Avoid database access on the main thread when opening the settings `getUserProfile()` was storing the user profile (DB read, avatar Base64 encoding, DB write) on the caller dispatcher, i.e. the main thread when called from a presenter, causing glitches in the screen transition. - Run the whole `getUserProfile()` on the session dispatcher. - Make `DatabaseSessionStore` main-safe by running the queries on the IO dispatcher. - Do not refresh the profile twice from the settings screen. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../account/PreferencesAccountPresenter.kt | 5 +- .../libraries/matrix/impl/RustMatrixClient.kt | 14 +- .../impl/DatabaseSessionStore.kt | 157 ++++++++++-------- 3 files changed, 93 insertions(+), 83 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt index 4e04cd0ab6..e20deb91cb 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt @@ -40,10 +40,7 @@ class PreferencesAccountPresenter( @Composable override fun present(): PreferencesAccountState { val matrixUser = matrixClient.userProfile.collectAsState() - LaunchedEffect(Unit) { - // Force a refresh of the profile - matrixClient.getUserProfile() - } + // Note: no need to force a refresh of the profile here, PreferencesRootPresenter, which embeds this presenter, already does it. val showLinkNewDevice by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) }.collectAsState(initial = false) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index a9eb22cc8b..44b33e74a7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -519,12 +519,14 @@ class RustMatrixClient( } } - override suspend fun getUserProfile(): Result = getProfile(sessionId) - .onSuccess { matrixUser -> - // Also update our session storage - _userProfile.emit(matrixUser) - storeUserProfile(matrixUser) - } + override suspend fun getUserProfile(): Result = withContext(sessionDispatcher) { + getProfile(sessionId) + .onSuccess { matrixUser -> + // Also update our session storage + _userProfile.emit(matrixUser) + storeUserProfile(matrixUser) + } + } /** * Store the user profile in the session storage, including a base64 encoded thumbnail of the avatar, diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index f183c42225..304a969f9d 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import timber.log.Timber @SingleIn(AppScope::class) @@ -51,85 +52,93 @@ class DatabaseSessionStore( } override suspend fun addSession(sessionData: SessionData) { - sessionDataMutex.withLock { - val lastUsageIndex = getLastUsageIndex() - database.sessionDataQueries.insertSessionData( - sessionData - .copy( - // position value does not really matter, so just use lastUsageIndex + 1 to ensure that - // the value is always greater than value of any existing account - position = lastUsageIndex + 1, - lastUsageIndex = lastUsageIndex + 1, - ) - .toDbModel() - ) + withContext(dispatchers.io) { + sessionDataMutex.withLock { + val lastUsageIndex = getLastUsageIndex() + database.sessionDataQueries.insertSessionData( + sessionData + .copy( + // position value does not really matter, so just use lastUsageIndex + 1 to ensure that + // the value is always greater than value of any existing account + position = lastUsageIndex + 1, + lastUsageIndex = lastUsageIndex + 1, + ) + .toDbModel() + ) + } } } override suspend fun updateData(sessionData: SessionData) { - sessionDataMutex.withLock { - val result = database.sessionDataQueries.selectByUserId(sessionData.userId) - .executeAsOneOrNull() - ?.toApiModel() + withContext(dispatchers.io) { + sessionDataMutex.withLock { + val result = database.sessionDataQueries.selectByUserId(sessionData.userId) + .executeAsOneOrNull() + ?.toApiModel() - if (result == null) { - Timber.e("User ${sessionData.userId} not found in session database") - return + if (result == null) { + Timber.e("User ${sessionData.userId} not found in session database") + return@withContext + } + // Copy new data from SDK, but keep application data + database.sessionDataQueries.updateSession( + sessionData.copy( + loginTimestamp = result.loginTimestamp, + position = result.position, + lastUsageIndex = result.lastUsageIndex, + userDisplayName = result.userDisplayName, + userAvatarUrl = result.userAvatarUrl, + userAvatarData = result.userAvatarData, + ).toDbModel() + ) } - // Copy new data from SDK, but keep application data - database.sessionDataQueries.updateSession( - sessionData.copy( - loginTimestamp = result.loginTimestamp, - position = result.position, - lastUsageIndex = result.lastUsageIndex, - userDisplayName = result.userDisplayName, - userAvatarUrl = result.userAvatarUrl, - userAvatarData = result.userAvatarData, - ).toDbModel() - ) } } override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?, avatarData: String?) { - sessionDataMutex.withLock { + withContext(dispatchers.io) { + sessionDataMutex.withLock { + val result = database.sessionDataQueries.selectByUserId(sessionId) + .executeAsOneOrNull() + ?.toApiModel() + if (result == null) { + Timber.e("User $sessionId not found in session database") + return@withContext + } + database.sessionDataQueries.updateSession( + result.copy( + userDisplayName = displayName, + userAvatarUrl = avatarUrl, + userAvatarData = avatarData, + ).toDbModel() + ) + } + } + } + + override suspend fun setLatestSession(sessionId: String) { + withContext(dispatchers.io) { + val latestSession = getLatestSession() + if (latestSession?.userId == sessionId) { + // Already the latest session + return@withContext + } + val lastUsageIndex = latestSession?.lastUsageIndex ?: 0 val result = database.sessionDataQueries.selectByUserId(sessionId) .executeAsOneOrNull() ?.toApiModel() if (result == null) { Timber.e("User $sessionId not found in session database") - return + return@withContext + } + sessionDataMutex.withLock { + // Update lastUsageIndex of the session + database.sessionDataQueries.updateSession( + result.copy( + lastUsageIndex = lastUsageIndex + 1, + ).toDbModel() + ) } - database.sessionDataQueries.updateSession( - result.copy( - userDisplayName = displayName, - userAvatarUrl = avatarUrl, - userAvatarData = avatarData, - ).toDbModel() - ) - } - } - - override suspend fun setLatestSession(sessionId: String) { - val latestSession = getLatestSession() - if (latestSession?.userId == sessionId) { - // Already the latest session - return - } - val lastUsageIndex = latestSession?.lastUsageIndex ?: 0 - val result = database.sessionDataQueries.selectByUserId(sessionId) - .executeAsOneOrNull() - ?.toApiModel() - if (result == null) { - Timber.e("User $sessionId not found in session database") - return - } - sessionDataMutex.withLock { - // Update lastUsageIndex of the session - database.sessionDataQueries.updateSession( - result.copy( - lastUsageIndex = lastUsageIndex + 1, - ).toDbModel() - ) } } @@ -140,32 +149,32 @@ class DatabaseSessionStore( ?: -1L } - override suspend fun getLatestSession(): SessionData? { - return sessionDataMutex.withLock { + override suspend fun getLatestSession(): SessionData? = withContext(dispatchers.io) { + sessionDataMutex.withLock { database.sessionDataQueries.selectLatest() .executeAsOneOrNull() ?.toApiModel() } } - override suspend fun getSession(sessionId: String): SessionData? { - return sessionDataMutex.withLock { + override suspend fun getSession(sessionId: String): SessionData? = withContext(dispatchers.io) { + sessionDataMutex.withLock { database.sessionDataQueries.selectByUserId(sessionId) .executeAsOneOrNull() ?.toApiModel() } } - override suspend fun getAllSessions(): List { - return sessionDataMutex.withLock { + override suspend fun getAllSessions(): List = withContext(dispatchers.io) { + sessionDataMutex.withLock { database.sessionDataQueries.selectAll() .executeAsList() .map { it.toApiModel() } } } - override suspend fun numberOfSessions(): Int { - return sessionDataMutex.withLock { + override suspend fun numberOfSessions(): Int = withContext(dispatchers.io) { + sessionDataMutex.withLock { database.sessionDataQueries.count() .executeAsOneOrNull() ?.toInt() @@ -181,8 +190,10 @@ class DatabaseSessionStore( } override suspend fun removeSession(sessionId: String) { - sessionDataMutex.withLock { - database.sessionDataQueries.removeSession(sessionId) + withContext(dispatchers.io) { + sessionDataMutex.withLock { + database.sessionDataQueries.removeSession(sessionId) + } } } } From 404c26c62ea99eb6047a19d3af1db499455fb3e7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 14:33:15 +0200 Subject: [PATCH 05/33] Fetch and map the thread list items off the main thread The thread list subscription was launched in the room coroutine scope, which runs on the main thread, so the initial sync FFI call and the mapping of all the items and diffs were done on the main thread every time a room was opened. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../android/libraries/matrix/impl/room/JoinedRustRoom.kt | 1 + .../matrix/impl/room/threads/RustThreadsListService.kt | 5 +++++ .../matrix/impl/room/threads/RustThreadsListServiceTest.kt | 2 ++ 3 files changed, 8 insertions(+) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 17a3910c98..3be63f4f97 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -158,6 +158,7 @@ class JoinedRustRoom( inner = innerRoom.threadListService(), contentMapper = TimelineEventContentMapper(), roomCoroutineScope = roomCoroutineScope, + roomDispatcher = roomDispatcher, ) override val syncUpdateFlow = flow { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt index a74c5bc378..98248b183b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt @@ -17,10 +17,12 @@ import io.element.android.libraries.matrix.api.room.threads.ThreadsListService import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.event.map import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart @@ -33,6 +35,7 @@ import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService class RustThreadsListService( private val inner: InnerThreadListService, private val roomCoroutineScope: CoroutineScope, + private val roomDispatcher: CoroutineDispatcher, private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(), ) : ThreadsListService { private var itemSubscriptionJob: Job? = null @@ -63,6 +66,8 @@ class RustThreadsListService( updated.apply(diff, contentMapper) items.value = updated } + // Fetching and mapping the items can be costly, do not do it on the main thread + .flowOn(roomDispatcher) .launchIn(roomCoroutineScope) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt index 0fc9ad0603..fd9f311b95 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt @@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -122,6 +123,7 @@ class RustThreadsListServiceTest { ) = RustThreadsListService( inner = inner, roomCoroutineScope = backgroundScope, + roomDispatcher = StandardTestDispatcher(testScheduler), ) private fun aRustThreadListUpdate() = ThreadListUpdate.Append( From 149dedcc03000a1cb57ef5d47d16ea7bd7d0f3ee Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 14:33:16 +0200 Subject: [PATCH 06/33] Store the user profile off the main thread when it is updated by the SDK Same issue as when refreshing the profile manually: the profile listener stored the profile (Base64 encoding and session database access) from the session coroutine scope, which runs on the main thread. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../element/android/libraries/matrix/impl/RustMatrixClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index a9eb22cc8b..a30d63111a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -322,7 +322,7 @@ class RustMatrixClient( override fun onUpdate(profile: UserProfile) { val matrixUser = profile.map() _userProfile.tryEmit(matrixUser) - sessionCoroutineScope.launch { + sessionCoroutineScope.launch(sessionDispatcher) { storeUserProfile(matrixUser) } } From 9be2a31c5f9a759c9d030b413a856c6f3d21daa6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 14:33:16 +0200 Subject: [PATCH 07/33] Schedule the background search backfill off the main thread Checking if the work is already scheduled does a blocking WorkManager query, which was done on the main thread at session start. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../element/android/libraries/matrix/impl/RustMatrixClient.kt | 3 +++ .../libraries/matrix/impl/RustMatrixClientFactoryTest.kt | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index a30d63111a..84b372eb12 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -118,6 +118,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -1034,6 +1035,8 @@ class RustMatrixClient( searchBackfillService.cancelPeriodicSearchBackfill() } } + // Checking the pending work is a blocking call, do not do it on the main thread + .flowOn(sessionDispatcher) .launchIn(sessionCoroutineScope) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index 0ed71e3bfe..d5d8c62515 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -33,7 +33,9 @@ import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.rustcomponents.sdk.Client @@ -41,6 +43,7 @@ import org.matrix.rustcomponents.sdk.ClientDelegate import java.io.File import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService +@OptIn(ExperimentalCoroutinesApi::class) class RustMatrixClientFactoryTest { @Test fun test() = runTest { @@ -51,6 +54,7 @@ class RustMatrixClientFactoryTest { val result = sut.create(aSessionData()) assertThat(result.sessionId).isEqualTo(SessionId("@alice:server.org")) + runCurrent() // 2 workers are scheduled: one for the search backfill and one for the vacuum scheduleWorkersLambda.assertions().isCalledExactly(2) result.destroy() From 6efb10df11a36c2fb1ca379a6b3d33c45b0c8d84 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 14:33:17 +0200 Subject: [PATCH 08/33] Write the push history off the main thread The push history was written to the database synchronously, from the app coroutine scope which runs on the main thread, for every received push. Make PushHistoryService.onPushResult suspend and run all the database queries on the IO dispatcher. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../impl/history/DefaultPushHistoryService.kt | 47 ++++++++++--------- .../push/impl/history/PushHistoryService.kt | 12 ++--- .../impl/history/FakePushHistoryService.kt | 2 +- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt index ab6f01e423..f1c4c9d9a3 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt @@ -14,6 +14,7 @@ import android.os.PowerManager import androidx.core.content.getSystemService import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.core.EventId @@ -23,23 +24,25 @@ import io.element.android.libraries.push.impl.PushDatabase import io.element.android.libraries.push.impl.db.PushHistory import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.withContext import kotlin.time.Instant @ContributesBinding(AppScope::class) class DefaultPushHistoryService( private val pushDatabase: PushDatabase, private val systemClock: SystemClock, + private val dispatchers: CoroutineDispatchers, @ApplicationContext context: Context, ) : PushHistoryService { private val powerManager = context.getSystemService() private val packageName = context.packageName - override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result { - return runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() } + override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result = withContext(dispatchers.io) { + runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() } } - override suspend fun insertOrUpdatePushRequests(pushRequests: List): Result { - return runCatchingExceptions { + override suspend fun insertOrUpdatePushRequests(pushRequests: List): Result = withContext(dispatchers.io) { + runCatchingExceptions { pushDatabase.transaction { for (request in pushRequests) { pushDatabase.pushRequestQueries.insertPushRequest(request) @@ -48,8 +51,8 @@ class DefaultPushHistoryService( } } - override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> { - return runCatchingExceptions { + override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> = withContext(dispatchers.io) { + runCatchingExceptions { pushDatabase.transactionWithResult { val sinceTimeMillis = since?.toEpochMilliseconds() ?: 0 pushDatabase.pushRequestQueries.selectAllPendingForSession(sessionId.value, sinceTimeMillis).executeAsList() @@ -57,14 +60,14 @@ class DefaultPushHistoryService( } } - override suspend fun removeOldPushRequests(sessionId: SessionId): Result { - return runCatchingExceptions { + override suspend fun removeOldPushRequests(sessionId: SessionId): Result = withContext(dispatchers.io) { + runCatchingExceptions { val keepAmount = 100L pushDatabase.pushRequestQueries.removeOldest(keepAmount) } } - override fun onPushResult( + override suspend fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, @@ -91,19 +94,21 @@ class DefaultPushHistoryService( } }.takeIf { it.isNotEmpty() } - pushDatabase.pushHistoryQueries.insertPushHistory( - PushHistory( - pushDate = systemClock.epochMillis(), - providerInfo = providerInfo, - eventId = eventId?.value, - roomId = roomId?.value, - sessionId = sessionId?.value, - hasBeenResolved = if (hasBeenResolved) 1 else 0, - comment = finalComment, + withContext(dispatchers.io) { + pushDatabase.pushHistoryQueries.insertPushHistory( + PushHistory( + pushDate = systemClock.epochMillis(), + providerInfo = providerInfo, + eventId = eventId?.value, + roomId = roomId?.value, + sessionId = sessionId?.value, + hasBeenResolved = if (hasBeenResolved) 1 else 0, + comment = finalComment, + ) ) - ) - // Keep only the last 1_000 events - pushDatabase.pushHistoryQueries.removeOldest(1_000) + // Keep only the last 1_000 events + pushDatabase.pushHistoryQueries.removeOldest(1_000) + } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt index 3996924322..298abbd441 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt @@ -20,7 +20,7 @@ interface PushHistoryService { * Create a new push history entry. * Do not use directly, prefer using the extension functions. */ - fun onPushResult( + suspend fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, @@ -52,7 +52,7 @@ interface PushHistoryService { suspend fun removeOldPushRequests(sessionId: SessionId): Result } -fun PushHistoryService.onInvalidPushReceived( +suspend fun PushHistoryService.onInvalidPushReceived( providerInfo: String, data: String, ) = onPushResult( @@ -65,7 +65,7 @@ fun PushHistoryService.onInvalidPushReceived( comment = "Invalid or ignored push data:\n$data", ) -fun PushHistoryService.onUnableToRetrieveSession( +suspend fun PushHistoryService.onUnableToRetrieveSession( providerInfo: String, eventId: EventId, roomId: RoomId, @@ -80,7 +80,7 @@ fun PushHistoryService.onUnableToRetrieveSession( comment = "Unable to retrieve session: $reason", ) -fun PushHistoryService.onUnableToResolveEvent( +suspend fun PushHistoryService.onUnableToResolveEvent( providerInfo: String, eventId: EventId, roomId: RoomId, @@ -96,7 +96,7 @@ fun PushHistoryService.onUnableToResolveEvent( comment = "Unable to resolve event: $reason", ) -fun PushHistoryService.onSuccess( +suspend fun PushHistoryService.onSuccess( providerInfo: String, eventId: EventId, roomId: RoomId, @@ -117,7 +117,7 @@ fun PushHistoryService.onSuccess( }, ) -fun PushHistoryService.onDiagnosticPush( +suspend fun PushHistoryService.onDiagnosticPush( providerInfo: String, ) = onPushResult( providerInfo = providerInfo, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt index aa9612717a..e7cd342514 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt @@ -30,7 +30,7 @@ class FakePushHistoryService( private val getPendingPushRequests: (SessionId, Instant?) -> Result> = { _, _ -> lambdaError() }, private val removeOldPushRequests: (SessionId) -> Result = { lambdaError() }, ) : PushHistoryService { - override fun onPushResult( + override suspend fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, From 865cca67641b51b9c10a978e767ec720dc0695b8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 14:33:17 +0200 Subject: [PATCH 09/33] Load the push distributors off the main thread in the notification settings Getting the distributors queries the PackageManager and the Play Services, it was done during the first composition of the screen. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../NotificationSettingsPresenter.kt | 20 ++++++++++++------- .../NotificationSettingsPresenterTest.kt | 2 ++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt index d2e5fc5b50..c08427f387 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingStateNoSuccess +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -57,6 +58,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.time.Duration.Companion.seconds @@ -76,6 +78,7 @@ class NotificationSettingsPresenter( @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val featureFlagService: FeatureFlagService, + private val dispatchers: CoroutineDispatchers, ) : Presenter { // Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks // its `.tmp` file, but the persist+recreate window outside the copier is unprotected; @@ -111,16 +114,19 @@ class NotificationSettingsPresenter( } // List of PushProvider -> Distributor - val distributors = remember { - pushService.getAvailablePushProviders() - .flatMap { pushProvider -> - pushProvider.getDistributors().map { distributor -> - pushProvider to distributor + // Getting the distributors can query the PackageManager and the Play Services, so do it off the main thread + val distributors by produceState(emptyList>()) { + value = withContext(dispatchers.io) { + pushService.getAvailablePushProviders() + .flatMap { pushProvider -> + pushProvider.getDistributors().map { distributor -> + pushProvider to distributor + } } - } + } } // List of Distributors - val availableDistributors = remember { + val availableDistributors = remember(distributors) { distributors.map { it.second }.toImmutableList() } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt index f189949cdc..e37d39d775 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -40,6 +40,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope @@ -1176,6 +1177,7 @@ class NotificationSettingsPresenterTest { stringProvider = FakeStringProvider(), sessionCoroutineScope = backgroundScope, featureFlagService = featureFlagService, + dispatchers = testCoroutineDispatchers(), ) } } From 6049f25a3e888646de34318f73909a0068b6db86 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Oct 2026 14:33:18 +0200 Subject: [PATCH 10/33] Create the LocalMedia off the main thread when picking or sharing media LocalMediaFactory.createFromUri queries the ContentResolver and can decode the media, it was called on the main thread from the picker callbacks and when sharing a file. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../MessageComposerPresenter.kt | 89 +++++++++++-------- ...essageComposerPresenterSlashCommandTest.kt | 1 + .../MessageComposerPresenterTest.kt | 1 + 3 files changed, 53 insertions(+), 38 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 2974b99696..5ffd90b3ab 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -110,6 +111,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.time.Duration.Companion.seconds import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes @@ -145,6 +147,7 @@ class MessageComposerPresenter( private val featureFlagService: FeatureFlagService, private val contentScannerService: ContentScannerService, private val contentValidationCache: EventContentValidationCache, + private val dispatchers: CoroutineDispatchers, ) : Presenter { @AssistedFactory interface Factory { @@ -193,22 +196,22 @@ class MessageComposerPresenter( .collectAsState(initial = false) val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> - handlePickedMedia(uri, mimeType) + localCoroutineScope.handlePickedMedia(uri, mimeType) } val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris -> - handlePickedMediaList(uris) + localCoroutineScope.handlePickedMediaList(uris) } val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris -> - handlePickedMediaList(uris, sendAsFile = true) + localCoroutineScope.handlePickedMediaList(uris, sendAsFile = true) } val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> - handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) + localCoroutineScope.handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> - handlePickedMedia(uri, MimeTypes.Jpeg) + localCoroutineScope.handlePickedMedia(uri, MimeTypes.Jpeg) } val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri -> - handlePickedMedia(uri, MimeTypes.Mp4) + localCoroutineScope.handlePickedMedia(uri, MimeTypes.Mp4) } val isFullScreen = rememberSaveable { mutableStateOf(false) @@ -286,17 +289,16 @@ class MessageComposerPresenter( } is MessageComposerEvent.SendUri -> { val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId - sessionCoroutineScope.sendAttachment( - attachment = Attachment.Media( - localMedia = localMediaFactory.createFromUri( - uri = event.uri, - mimeType = null, - name = null, - formattedFileSize = null - ), - ), - inReplyToEventId = inReplyToEventId, - ) + sessionCoroutineScope.launch { + val localMedia = createLocalMedia( + uri = event.uri, + mimeType = null, + ) + sendAttachment( + attachment = Attachment.Media(localMedia), + inReplyToEventId = inReplyToEventId, + ) + } resetComposerModeAfterAttaching() } @@ -640,17 +642,28 @@ class MessageComposerPresenter( } } - private fun handlePickedMedia( - uri: Uri?, - mimeType: String? = null, - sendAsFile: Boolean = false, - ) { - uri ?: return - val localMedia = localMediaFactory.createFromUri( + // Creating the LocalMedia queries the ContentResolver and can decode the media, so do it off the main thread + private suspend fun createLocalMedia( + uri: Uri, + mimeType: String?, + ) = withContext(dispatchers.io) { + localMediaFactory.createFromUri( uri = uri, mimeType = mimeType, name = null, - formattedFileSize = null + formattedFileSize = null, + ) + } + + private fun CoroutineScope.handlePickedMedia( + uri: Uri?, + mimeType: String? = null, + sendAsFile: Boolean = false, + ) = launch { + uri ?: return@launch + val localMedia = createLocalMedia( + uri = uri, + mimeType = mimeType, ) val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile) val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId @@ -659,7 +672,7 @@ class MessageComposerPresenter( resetComposerModeAfterAttaching() } - private fun handlePickedMediaList( + private fun CoroutineScope.handlePickedMediaList( uris: List, sendAsFile: Boolean = false, ) { @@ -668,19 +681,19 @@ class MessageComposerPresenter( handlePickedMedia(uris.first(), sendAsFile = sendAsFile) return } - val attachments = uris.map { uri -> - val localMedia = localMediaFactory.createFromUri( - uri = uri, - mimeType = null, - name = null, - formattedFileSize = null, - ) - Attachment.Media(localMedia, sendAsFile = sendAsFile) - }.toImmutableList() - val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId - navigator.navigateToPreviewAttachments(attachments, inReplyToEventId) + launch { + val attachments = uris.map { uri -> + val localMedia = createLocalMedia( + uri = uri, + mimeType = null, + ) + Attachment.Media(localMedia, sendAsFile = sendAsFile) + }.toImmutableList() + val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId + navigator.navigateToPreviewAttachments(attachments, inReplyToEventId) - resetComposerModeAfterAttaching() + resetComposerModeAfterAttaching() + } } private fun resetComposerModeAfterAttaching() { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt index a8f6c338f5..4535607cb6 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt @@ -324,6 +324,7 @@ class MessageComposerPresenterSlashCommandTest { featureFlagService = featureFlagService, contentScannerService = { _, _ -> }, contentValidationCache = InMemoryEventContentValidationCache(), + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 3253e6088d..74b8047449 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -1723,6 +1723,7 @@ class MessageComposerPresenterTest : RobolectricTest() { featureFlagService = featureFlagService, contentScannerService = { _, _ -> }, contentValidationCache = InMemoryEventContentValidationCache(), + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled From d5780507c90f35df7afa2b8a3720468207dd486a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 Oct 2026 09:26:55 +0200 Subject: [PATCH 11/33] Make storeUserProfile main-safe instead of wrapping getUserProfile --- .../libraries/matrix/impl/RustMatrixClient.kt | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 44b33e74a7..e81eac569a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -519,20 +519,18 @@ class RustMatrixClient( } } - override suspend fun getUserProfile(): Result = withContext(sessionDispatcher) { - getProfile(sessionId) - .onSuccess { matrixUser -> - // Also update our session storage - _userProfile.emit(matrixUser) - storeUserProfile(matrixUser) - } - } + override suspend fun getUserProfile(): Result = getProfile(sessionId) + .onSuccess { matrixUser -> + // Also update our session storage + _userProfile.emit(matrixUser) + storeUserProfile(matrixUser) + } /** * Store the user profile in the session storage, including a base64 encoded thumbnail of the avatar, * so that the avatar can be rendered without having to use this client. */ - private suspend fun storeUserProfile(matrixUser: MatrixUser) { + private suspend fun storeUserProfile(matrixUser: MatrixUser): Unit = withContext(sessionDispatcher) { val avatarUrl = matrixUser.avatarUrl val storedSession = sessionStore.getSession(sessionId.value) val avatarData = when { From 3170ff44222952932358027740bdf66f36fcd78a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 Oct 2026 10:42:05 +0200 Subject: [PATCH 12/33] Remove useless Unit type. --- .../element/android/libraries/matrix/impl/RustMatrixClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index e81eac569a..74180e4e0b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -530,7 +530,7 @@ class RustMatrixClient( * Store the user profile in the session storage, including a base64 encoded thumbnail of the avatar, * so that the avatar can be rendered without having to use this client. */ - private suspend fun storeUserProfile(matrixUser: MatrixUser): Unit = withContext(sessionDispatcher) { + private suspend fun storeUserProfile(matrixUser: MatrixUser) = withContext(sessionDispatcher) { val avatarUrl = matrixUser.avatarUrl val storedSession = sessionStore.getSession(sessionId.value) val avatarData = when { From 092cded211e6d37849dc2a9db3a32919e941a90e Mon Sep 17 00:00:00 2001 From: ElementBot <110224175+ElementBot@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:21:21 +0100 Subject: [PATCH 13/33] Update Matrix Rust SDK to 26.10.07-nightly (#7880) * Update Matrix Rust SDK to 26.10.07-nightly * Adapt to the new QR code login task API `generate()` and `scan()` of `GrantLoginWithQrCodeHandler` and `LoginWithQrCodeHandler` now return a task instead of suspending until the flow is over. Wait for the task to complete, and cancel it if the coroutine is cancelled, so that the flow is also stopped on the Rust side. Co-Authored-By: Claude Opus 5.5 (1M context) * Add tests for the cancellation of the QR code login tasks Check that cancelling the coroutine of the link new device handlers and of the QR code login cancels the task returned by the SDK, so that the flow is stopped on the Rust side. Co-Authored-By: Claude Opus 5.5 (1M context) * Fix import ordering in the link new device handler tests ktlint expects the imports to be sorted in lexicographic order. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Benoit Marty Co-authored-by: Claude Opus 5.5 (1M context) --- gradle/libs.versions.toml | 2 +- .../auth/RustMatrixAuthenticationService.kt | 9 ++++- .../linknewdevice/RustLinkDesktopHandler.kt | 12 +++++- .../linknewdevice/RustLinkMobileHandler.kt | 12 +++++- .../RustMatrixAuthenticationServiceTest.kt | 39 +++++++++++++++++++ .../FakeFfiGrantLoginWithQrCodeHandler.kt | 16 ++++++-- .../fakes/FakeFfiGrantLoginWithQrCodeTask.kt | 20 ++++++++++ .../fakes/FakeFfiLoginWithQrCodeHandler.kt | 16 ++++++-- .../fakes/FakeFfiLoginWithQrCodeTask.kt | 20 ++++++++++ .../RustLinkDesktopHandlerTest.kt | 22 +++++++++++ .../RustLinkMobileHandlerTest.kt | 22 +++++++++++ 11 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeTask.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeTask.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 965aee55c0..9994348e1d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -186,7 +186,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # it as a plain `require`: without this, a library built against a newer SDK would silently drag this app # onto it. With it, this pin always wins, and a library that needs something newer fails at configuration # time naming both versions instead of at runtime with NoSuchMethodError. -matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.10.05-nightly" } } +matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.10.07-nightly" } } rustls_platform_verifier = "org.rustls:rustls-platform-verifier:0.2.0" # Others diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 90d640e82e..c3644eb959 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -345,7 +345,14 @@ class RustMatrixAuthenticationService( it.scan( qrCodeData = qrCodeData.rustQrCodeData, progressListener = progressListener, - ) + ).use { task -> + try { + task.wait() + } catch (e: CancellationException) { + task.cancel() + throw e + } + } } // Ensure that the user is not already logged in with the same account ensureNotAlreadyLoggedIn(client) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt index 8c9695ae4b..e2b417870e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep import io.element.android.libraries.matrix.api.logs.LoggerTags import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -53,8 +54,15 @@ class RustLinkDesktopHandler( } } } - ) - // We emit Done in case the progress listener was deallocated before scan() sent the Done + ).use { task -> + try { + task.wait() + } catch (e: CancellationException) { + task.cancel() + throw e + } + } + // We emit Done in case the progress listener was deallocated before the task sent the Done _linkDesktopStep.emit(LinkDesktopStep.Done) } catch (e: QrCodeDecodeException) { Timber.tag(tag.value).w(e, "Invalid QR code scanned") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt index 33f1ad736d..868721ced9 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt @@ -11,6 +11,7 @@ import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep import io.element.android.libraries.matrix.api.logs.LoggerTags +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -48,8 +49,15 @@ class RustLinkMobileHandler( } } } - ) - // We emit Done in case the progress listener was deallocated before generate() sent the Done + ).use { task -> + try { + task.wait() + } catch (e: CancellationException) { + task.cancel() + throw e + } + } + // We emit Done in case the progress listener was deallocated before the task sent the Done _linkMobileStep.emit(LinkMobileStep.Done) } catch (e: HumanQrGrantLoginException.Expired) { // Note: the SDK does not return this error, but is returning `.NotFound` instead when the QR code expires. We catch both just in case. diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index df564ca710..95047ea452 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -6,6 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.libraries.matrix.impl.auth import com.google.common.truth.Truth.assertThat @@ -36,7 +38,11 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -171,6 +177,39 @@ class RustMatrixAuthenticationServiceTest { closeResult.assertions().isCalledOnce() } + @Test + fun `loginWithQrCode cancels the login task when it is cancelled`() = runTest { + val cancelResult = lambdaRecorder {} + val sut = createRustMatrixAuthenticationService( + clientBuilderProvider = FakeClientBuilderProvider( + provideResult = { + FakeFfiClientBuilder( + buildResult = { + FakeFfiClient( + newLoginWithQrCodeHandlerResult = { + FakeFfiLoginWithQrCodeHandler( + scanResult = { awaitCancellation() }, + cancelResult = cancelResult, + ) + }, + closeResult = {}, + ) + }, + ) + }, + ), + ) + + val job = backgroundScope.launch { + sut.loginWithQrCode(aSdkQrCodeLoginData()) {} + } + runCurrent() + cancelResult.assertions().isNeverCalled() + job.cancel() + runCurrent() + cancelResult.assertions().isCalledOnce() + } + private fun aSdkQrCodeLoginData() = SdkQrCodeLoginData( FakeFfiQrCodeData( baseUrlResult = { A_HOMESERVER_URL }, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt index 0899b16325..c205010ba4 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgress import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgressListener import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeTask import org.matrix.rustcomponents.sdk.GrantQrLoginProgress import org.matrix.rustcomponents.sdk.GrantQrLoginProgressListener import org.matrix.rustcomponents.sdk.NoHandle @@ -18,21 +19,28 @@ import org.matrix.rustcomponents.sdk.QrCodeData class FakeFfiGrantLoginWithQrCodeHandler( private val generateResult: suspend () -> Unit = {}, private val scanResult: suspend (QrCodeData) -> Unit = {}, + private val cancelResult: () -> Unit = {}, ) : GrantLoginWithQrCodeHandler(NoHandle) { private var generateProgressListener: GrantGeneratedQrLoginProgressListener? = null private var scanProgressListener: GrantQrLoginProgressListener? = null - override suspend fun generate(progressListener: GrantGeneratedQrLoginProgressListener) { + override fun generate(progressListener: GrantGeneratedQrLoginProgressListener): GrantLoginWithQrCodeTask { generateProgressListener = progressListener - generateResult() + return FakeFfiGrantLoginWithQrCodeTask( + waitResult = generateResult, + cancelResult = cancelResult, + ) } fun emitGenerateProgress(progress: GrantGeneratedQrLoginProgress) { generateProgressListener?.onUpdate(progress) } - override suspend fun scan(qrCodeData: QrCodeData, progressListener: GrantQrLoginProgressListener) { + override fun scan(qrCodeData: QrCodeData, progressListener: GrantQrLoginProgressListener): GrantLoginWithQrCodeTask { scanProgressListener = progressListener - scanResult(qrCodeData) + return FakeFfiGrantLoginWithQrCodeTask( + waitResult = { scanResult(qrCodeData) }, + cancelResult = cancelResult, + ) } fun emitScanProgress(progress: GrantQrLoginProgress) { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeTask.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeTask.kt new file mode 100644 index 0000000000..016267365a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeTask.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeTask +import org.matrix.rustcomponents.sdk.NoHandle + +class FakeFfiGrantLoginWithQrCodeTask( + private val waitResult: suspend () -> Unit = {}, + private val cancelResult: () -> Unit = {}, +) : GrantLoginWithQrCodeTask(NoHandle) { + override suspend fun wait() = waitResult() + override fun cancel() = cancelResult() + override fun close() = Unit +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt index 6e6b750643..457b080154 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import org.matrix.rustcomponents.sdk.GeneratedQrLoginProgressListener import org.matrix.rustcomponents.sdk.LoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.LoginWithQrCodeTask import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.QrCodeData import org.matrix.rustcomponents.sdk.QrLoginProgress @@ -17,16 +18,23 @@ import org.matrix.rustcomponents.sdk.QrLoginProgressListener class FakeFfiLoginWithQrCodeHandler( private val generateResult: suspend () -> Unit = {}, private val scanResult: suspend (QrCodeData) -> Unit = {}, + private val cancelResult: () -> Unit = {}, ) : LoginWithQrCodeHandler(NoHandle) { private var scanProgressListener: QrLoginProgressListener? = null - override suspend fun generate(progressListener: GeneratedQrLoginProgressListener) { - generateResult() + override fun generate(progressListener: GeneratedQrLoginProgressListener): LoginWithQrCodeTask { + return FakeFfiLoginWithQrCodeTask( + waitResult = generateResult, + cancelResult = cancelResult, + ) } - override suspend fun scan(qrCodeData: QrCodeData, progressListener: QrLoginProgressListener) { + override fun scan(qrCodeData: QrCodeData, progressListener: QrLoginProgressListener): LoginWithQrCodeTask { scanProgressListener = progressListener - scanResult(qrCodeData) + return FakeFfiLoginWithQrCodeTask( + waitResult = { scanResult(qrCodeData) }, + cancelResult = cancelResult, + ) } fun emitScanProgress(progress: QrLoginProgress) { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeTask.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeTask.kt new file mode 100644 index 0000000000..c3fe5e930a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeTask.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.LoginWithQrCodeTask +import org.matrix.rustcomponents.sdk.NoHandle + +class FakeFfiLoginWithQrCodeTask( + private val waitResult: suspend () -> Unit = {}, + private val cancelResult: () -> Unit = {}, +) : LoginWithQrCodeTask(NoHandle) { + override suspend fun wait() = waitResult() + override fun cancel() = cancelResult() + override fun close() = Unit +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt index 8a0a046946..7c8bd09416 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt @@ -17,9 +17,11 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuati import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler import io.element.android.libraries.matrix.test.QR_CODE_DATA import io.element.android.tests.testutils.ExpectedResult +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.match import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -140,6 +142,26 @@ class RustLinkDesktopHandlerTest { } } + @Test + fun `when handleScannedQrCode is cancelled, the task is cancelled`() = runTest { + val cancelResult = lambdaRecorder { } + val handler = FakeFfiGrantLoginWithQrCodeHandler( + scanResult = { awaitCancellation() }, + cancelResult = cancelResult, + ) + val sut = createRustLinkDesktopHandler( + handler, + ) + val job = backgroundScope.launch { + sut.handleScannedQrCode(QR_CODE_DATA) + } + runCurrent() + cancelResult.assertions().isNeverCalled() + job.cancel() + runCurrent() + cancelResult.assertions().isCalledOnce() + } + private fun TestScope.createRustLinkDesktopHandler( handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(), ) = RustLinkDesktopHandler( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt index f1f568e3ca..912c79c0ac 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt @@ -19,9 +19,11 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLogin import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE import io.element.android.tests.testutils.ExpectedResult +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.match import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -176,6 +178,26 @@ class RustLinkMobileHandlerTest { } } + @Test + fun `when start is cancelled, the task is cancelled`() = runTest { + val cancelResult = lambdaRecorder { } + val handler = FakeFfiGrantLoginWithQrCodeHandler( + generateResult = { awaitCancellation() }, + cancelResult = cancelResult, + ) + val sut = createRustLinkMobileHandler( + handler, + ) + val job = backgroundScope.launch { + sut.start() + } + runCurrent() + cancelResult.assertions().isNeverCalled() + job.cancel() + runCurrent() + cancelResult.assertions().isCalledOnce() + } + private fun TestScope.createRustLinkMobileHandler( handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(), ) = RustLinkMobileHandler( From 9cc5cf9a01d4f7f342ff35975cce29da92c039cb Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 13 Oct 2026 12:58:07 +0200 Subject: [PATCH 14/33] Add total unread notification count to home screen (#7891) * Add total unread notification count to home screen This will be calculated on each sync heartbeat (for now, using the state updates for the room list service). * Simplify `counter` parameter logic * Read the total unread count when the home screen is first displayed --------- Co-authored-by: Benoit Marty --- .../android/appnav/LoggedInEventProcessor.kt | 9 ++-- .../appnav/LoggedInEventProcessorTest.kt | 17 ++++--- .../features/home/impl/HomePresenter.kt | 17 +++++++ .../android/features/home/impl/HomeState.kt | 1 + .../home/impl/HomeStatePreviewParam.kt | 2 + .../android/features/home/impl/HomeView.kt | 3 ++ .../features/home/impl/HomePresenterTest.kt | 44 +++++++++++++++++++ .../libraries/matrix/api/MatrixClient.kt | 6 +++ .../libraries/matrix/api/sync/SyncService.kt | 7 +++ .../libraries/matrix/impl/RustMatrixClient.kt | 4 ++ .../matrix/impl/sync/RustSyncService.kt | 10 +++++ .../libraries/matrix/test/FakeMatrixClient.kt | 5 +++ .../matrix/test/sync/FakeSyncService.kt | 10 +++++ 13 files changed, 121 insertions(+), 14 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt index ac08795f67..7c944764fd 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -18,7 +18,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.RoomMembershipObserver -import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.ui.strings.CommonStrings @@ -34,7 +34,7 @@ class LoggedInEventProcessor( @Assisted private val snackbarDispatcher: SnackbarDispatcher, private val roomMembershipObserver: RoomMembershipObserver, private val sessionId: SessionId, - private val roomListService: RoomListService, + private val syncService: SyncService, private val notificationCleaner: NotificationCleaner, private val dispatchers: CoroutineDispatchers ) { @@ -73,9 +73,8 @@ class LoggedInEventProcessor( } .launchIn(childScope) - // Use the room list service state as a 'heartbeat' update to check existing notifications - roomListService.state - .filter { it == RoomListService.State.Running } + // Use the sync service heartbeat as a 'heartbeat' update to check existing notifications + syncService.heartBeat .onEach { notificationCleaner.clearReadRoomsNotifications(sessionId) } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt index 4d5cfe06f3..fd1f1b9d11 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt @@ -12,10 +12,9 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.RoomMembershipObserver -import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -155,24 +154,24 @@ class LoggedInEventProcessorTest { } @Test - fun `test notifications are cleared when room list service state is running`() = runTest { + fun `test notifications are cleared when sync heartbeat is emitted`() = runTest { val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> } val snackbarDispatcher = SnackbarDispatcher() val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda) - val roomListService = FakeRoomListService() + val syncService = FakeSyncService() val loggedInEventProcessor = createLoggedInEventProcessor( snackbarDispatcher = snackbarDispatcher, notificationCleaner = notificationCleaner, - roomListService = roomListService, + syncService = syncService, ) loggedInEventProcessor.observeEvents(backgroundScope) testScheduler.runCurrent() - // Simulate the room list service state changing to Running - roomListService.postState(RoomListService.State.Running) + // Simulate a sync heartbeat + syncService.emitHeartBeat() testScheduler.runCurrent() @@ -184,13 +183,13 @@ class LoggedInEventProcessorTest { snackbarDispatcher: SnackbarDispatcher, roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), sessionId: SessionId = A_SESSION_ID, - roomListService: FakeRoomListService = FakeRoomListService(), + syncService: FakeSyncService = FakeSyncService(), notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(), ) = LoggedInEventProcessor( snackbarDispatcher = snackbarDispatcher, roomMembershipObserver = roomMembershipObserver, sessionId = sessionId, - roomListService = roomListService, + syncService = syncService, notificationCleaner = notificationCleaner, dispatchers = testCoroutineDispatchers(), ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 95c98a4aec..95f6c229b8 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -34,9 +35,14 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds @Inject class HomePresenter( @@ -53,10 +59,20 @@ class HomePresenter( ) : Presenter { private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @Composable override fun present(): HomeState { val coroutineState = rememberCoroutineScope() val matrixUser by client.userProfile.collectAsState() + + val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) { + syncService.heartBeat + // Delay the heartbeat so the SDK has time to update the unread count before we read it. + .debounce(100.milliseconds) + .mapLatest { client.totalUnreadCount().getOrDefault(0).toInt() } + .collect { value = it } + } + val currentUserAndNeighbors by remember { combine( client.userProfile, @@ -130,6 +146,7 @@ class HomePresenter( snackbarMessage = snackbarMessage, canReportBug = canReportBug, showMultiAccountAnnouncement = showMultiAccountAnnouncement, + totalUnreadCount = totalUnreadCount, eventSink = ::handleEvent, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt index a49d3876e9..eb8e3ff2f1 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -29,6 +29,7 @@ data class HomeState( val snackbarMessage: SnackbarMessage?, val canReportBug: Boolean, val showMultiAccountAnnouncement: Boolean, + val totalUnreadCount: Int, val eventSink: (HomeEvent) -> Unit, ) { val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt index f86c32c532..4a82166d3e 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt @@ -64,6 +64,7 @@ internal fun aHomeState( homeSpacesState: HomeSpacesState = aHomeSpacesState(), canReportBug: Boolean = true, showMultiAccountAnnouncement: Boolean = false, + totalUnreadCount: Int = 0, eventSink: (HomeEvent) -> Unit = {} ) = HomeState( currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(), @@ -75,5 +76,6 @@ internal fun aHomeState( currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, + totalUnreadCount = totalUnreadCount, eventSink = eventSink, ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 6ff31a7c12..c32ed981c0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -259,6 +259,7 @@ private fun HomeScaffold( } } }, + totalUnreadCount = state.totalUnreadCount, ) }, floatingActionButtonPosition = FabPosition.Center, @@ -341,6 +342,7 @@ private fun HomeFloatingActionButton( @Composable private fun HomeBottomBar( + totalUnreadCount: Int, currentHomeNavigationBarItem: HomeNavigationBarItem, onItemClick: (HomeNavigationBarItem) -> Unit, modifier: Modifier = Modifier, @@ -361,6 +363,7 @@ private fun HomeBottomBar( tooltipLabel = stringResource(item.labelRes), isSelected = isSelected, onClick = { onItemClick(item) }, + counter = totalUnreadCount.takeIf { item == HomeNavigationBarItem.Chats && it > 0 } ) } } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index cf592d9e7e..fa0d40017e 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -40,9 +40,11 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds class HomePresenterTest { @get:Rule @@ -117,6 +119,38 @@ class HomePresenterTest { } } + @Test + fun `present - total unread count is updated on sync heartbeat`() = runTest { + var unreadCount = 0L + val syncService = FakeSyncService() + val presenter = createHomePresenter( + client = FakeMatrixClient(totalUnreadCountResult = { Result.success(unreadCount) }), + syncService = syncService, + ) + presenter.test { + assertThat(awaitItem().totalUnreadCount).isEqualTo(0) + unreadCount = 5 + syncService.emitHeartBeat() + advanceTimeBy(200.milliseconds) + assertThat(awaitItem().totalUnreadCount).isEqualTo(5) + } + } + + @Test + fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest { + val syncService = FakeSyncService() + val presenter = createHomePresenter( + client = FakeMatrixClient(totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }), + syncService = syncService, + ) + presenter.test { + assertThat(awaitItem().totalUnreadCount).isEqualTo(0) + syncService.emitHeartBeat() + advanceTimeBy(200.milliseconds) + expectNoEvents() + } + } + @Test fun `present - should start with no user and then load user with error`() = runTest { val matrixClient = FakeMatrixClient( @@ -245,6 +279,16 @@ class HomePresenterTest { .with(value(Announcement.MultiAccount)) } } + + @Test + fun `present - total unread count is loaded in the initial state`() = runTest { + val presenter = createHomePresenter( + client = FakeMatrixClient(totalUnreadCountResult = { Result.success(5) }), + ) + presenter.test { + assertThat(awaitItem().totalUnreadCount).isEqualTo(5) + } + } } internal fun createHomePresenter( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 3b4fd40885..71342d1b51 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -505,6 +505,12 @@ interface MatrixClient : ClientUrlContentFetcher { /** Returns a provider for the capabilities the homeserver advertises, such as whether the display name can be changed. */ fun homeserverCapabilities(): HomeserverCapabilitiesProvider + + /** + * Returns the total number of unread notifications across all rooms, as currently known by the client. + * This is a snapshot and may be out of date if the sync is running. + */ + fun totalUnreadCount(): Result } /** diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt index 92f60352d7..25fd1475f1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.api.sync +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow /** @@ -37,4 +38,10 @@ interface SyncService { * This is `false` only while the state is [SyncState.Offline]; an idle or errored sync still counts as online. */ val isOnline: StateFlow + + /** + * Flow that will emit every time the sync has received a response from the server, even if it was empty. + * Useful for triggering UI updates when the sync is running. + */ + val heartBeat: Flow } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 74180e4e0b..dcf75b78bc 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -1024,6 +1024,10 @@ class RustMatrixClient( return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities()) } + override fun totalUnreadCount(): Result = runCatchingExceptions { + innerClient.totalUnreadNotifications().toLong() + } + private fun scheduleBackgroundSearchBackfill() { featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch) .distinctUntilChanged() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt index f7732cd9b7..d08e561fb5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt @@ -12,16 +12,20 @@ import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.impl.roomlist.stateFlow import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.RoomListServiceState import org.matrix.rustcomponents.sdk.SyncServiceState import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean @@ -78,4 +82,10 @@ class RustSyncService( .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle) override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline } + + // TODO actually use a heartbeat mechanism from the SDK, not simulate it with the room list service + override val heartBeat: Flow = inner.roomListService() + .stateFlow() + .filter { it == RoomListServiceState.RUNNING } + .map {} } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 6d45db5041..b864daefcc 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -102,6 +102,7 @@ class FakeMatrixClient( override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(), + private val totalUnreadCountResult: () -> Result = { Result.success(0L) }, private val accountManagementUrlResult: (AccountManagementAction?) -> Result = { lambdaError() }, private val resolveRoomAliasResult: suspend (RoomAlias) -> Result> = { Result.success( @@ -468,4 +469,8 @@ class FakeMatrixClient( override fun homeserverCapabilities(): HomeserverCapabilitiesProvider { return homeserverCapabilitiesProvider } + + override fun totalUnreadCount(): Result { + return totalUnreadCountResult() + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt index 5bb85fa488..174ec4ce62 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt @@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.test.sync import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -33,6 +35,14 @@ class FakeSyncService( override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline } + private val heartBeatFlow = MutableSharedFlow(extraBufferCapacity = 1) + + override val heartBeat: Flow = heartBeatFlow + + suspend fun emitHeartBeat() { + heartBeatFlow.emit(Unit) + } + suspend fun emitSyncState(syncState: SyncState) { syncStateFlow.emit(syncState) } From 4b97d6ab963ea3ea4e867ad7ae5a2179a0ed12d2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 Oct 2026 17:15:37 +0200 Subject: [PATCH 15/33] Disable location sharing when no MapTiler config is available LocationService now exposes the MapTiler config to use: the one provided by the homeserver takes precedence over the build time one, and null means that no map provider is configured. It is now session scoped, and isServiceAvailable() returns a StateFlow derived from this config. When no config is available: - the location sharing option is hidden from the composer, and location events cannot be opened in the location viewer; - the "Location sharing" entry is hidden from the settings; - location events in the timeline render a placeholder, without performing any request to MapTiler. The show and share location screens now get the config from LocationService rather than fetching the homeserver config by themselves. Closes element-hq/element-android-enterprise#69 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../android/appnav/LoggedInFlowNode.kt | 12 ++--- .../location/api/LocalMapTilerConfig.kt | 25 +++++++--- .../features/location/api/LocationService.kt | 15 +++++- .../features/location/api/StaticMapView.kt | 12 +++-- .../location/impl/DefaultLocationService.kt | 31 +++++++++--- .../impl/share/ShareLocationPresenter.kt | 13 ++--- .../location/impl/share/ShareLocationState.kt | 3 +- .../share/ShareLocationStatePreviewParam.kt | 8 +-- .../location/impl/share/ShareLocationView.kt | 4 +- .../impl/show/ShowLocationPresenter.kt | 12 ++--- .../location/impl/show/ShowLocationState.kt | 3 +- .../show/ShowLocationStatePreviewParam.kt | 14 ++++-- .../location/impl/show/ShowLocationView.kt | 4 +- .../impl/DefaultLocationServiceTest.kt | 49 +++++++++++++++++-- .../DefaultShareLocationEntryPointTest.kt | 3 +- .../impl/share/ShareLocationPresenterTest.kt | 19 ++++--- .../show/DefaultShowLocationEntryPointTest.kt | 3 +- .../impl/show/ShowLocationPresenterTest.kt | 24 ++++----- features/location/test/build.gradle.kts | 1 + .../location/test/FakeLocationService.kt | 15 +++++- .../messages/impl/MessagesFlowNode.kt | 2 +- .../MessageComposerPresenter.kt | 7 +-- features/preferences/impl/build.gradle.kts | 2 + .../impl/root/PreferencesRootPresenter.kt | 5 ++ .../impl/root/PreferencesRootState.kt | 1 + .../root/PreferencesRootStatePreviewParam.kt | 4 ++ .../impl/root/PreferencesRootView.kt | 12 +++-- .../impl/root/PreferencesRootPresenterTest.kt | 15 ++++++ 28 files changed, 212 insertions(+), 106 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index fed49bdeab..188346f12a 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope @@ -52,13 +51,13 @@ import io.element.android.features.callnative.api.NativeCallHost import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService -import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint import io.element.android.features.location.api.LocalMapTilerConfig +import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus @@ -153,7 +152,7 @@ class LoggedInFlowNode( private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher, private val createRoomEntryPoint: CreateRoomEntryPoint, private val activeLiveLocationShareManager: ActiveLiveLocationShareManager, - private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, + private val locationService: LocationService, private val loggedInEventProcessorFactory: LoggedInEventProcessor.Factory, ) : BaseFlowNode( backstack = BackStack( @@ -636,10 +635,7 @@ class LoggedInFlowNode( enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId) }.collectAsState(SemanticColorsLightDark.default) - val currentMapTilerConfig = LocalMapTilerConfig.current - val updatedMapTilerConfig by produceState(currentMapTilerConfig) { - value = customMapTilerConfigProvider.get().getOrNull() ?: currentMapTilerConfig - } + val mapTilerConfig by locationService.mapTilerConfig.collectAsState() ElementThemeApp( appPreferencesStore = appPreferencesStore, @@ -648,7 +644,7 @@ class LoggedInFlowNode( compoundDark = colors.dark, buildMeta = buildMeta, ) { - CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) { + CompositionLocalProvider(LocalMapTilerConfig provides mapTilerConfig) { val isOnline by syncService.isOnline.collectAsState() // Outside the connectivity indicator so the two strips stack rather than fight: an // offline banner during a call belongs under the call bar, not over it. diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt index 752dd2401c..00f9d5190c 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt @@ -11,13 +11,22 @@ import androidx.compose.runtime.staticCompositionLocalOf import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig /** - * Provides a [MapTilerConfig] for the MapLibre maps. + * Provides the [MapTilerConfig] for the MapLibre maps, or null if no map provider is configured. */ -val LocalMapTilerConfig = staticCompositionLocalOf { - MapTilerConfig( - apiKey = BuildConfig.MAPTILER_API_KEY, - lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID, - baseUrl = BuildConfig.MAPTILER_BASE_URL - ) +val LocalMapTilerConfig = staticCompositionLocalOf { buildTimeMapTilerConfig() } + +/** + * The [MapTilerConfig] defined at build time, or null if no MapTiler API key has been provided. + */ +fun buildTimeMapTilerConfig(): MapTilerConfig? { + return BuildConfig.MAPTILER_API_KEY + .takeIf { it.isNotEmpty() } + ?.let { apiKey -> + MapTilerConfig( + apiKey = apiKey, + lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID, + darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID, + baseUrl = BuildConfig.MAPTILER_BASE_URL + ) + } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt index 41c58c396f..02cb7bba14 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt @@ -8,10 +8,21 @@ package io.element.android.features.location.api +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig +import io.element.android.libraries.core.coroutine.mapState +import kotlinx.coroutines.flow.StateFlow + /** - * Tells whether the location features can be used at all, which depends on the build having a map provider configured. + * Tells whether the location features can be used at all, which depends on a map provider being configured, + * either by the homeserver or at build time. */ interface LocationService { + /** + * The [MapTilerConfig] to use to render the maps, or null if no map provider is configured. + * The configuration provided by the homeserver takes precedence over the one defined at build time. + */ + val mapTilerConfig: StateFlow + /** Whether location sharing and map rendering are available; `false` disables the whole feature in the UI. */ - fun isServiceAvailable(): Boolean + fun isServiceAvailable(): StateFlow = mapTilerConfig.mapState { it != null } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index b3149964e4..f6a70f6653 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -50,11 +50,12 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables /** * Shows a static map image downloaded via a third party service's static maps API. * - * Handles 4 distinct cases: + * Handles 5 distinct cases: * 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching * 2. Null location - shows blurred placeholder, no pin, no loading - * 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator - * 4. Success (location != null, loaded) - shows actual map with pin + * 3. Null mapTilerConfig (no map provider configured) - shows blurred placeholder, no pin, no fetching + * 4. Loading (location != null, fetching) - shows blurred placeholder with loading indicator + * 5. Success (location != null, loaded) - shows actual map with pin */ @Composable fun StaticMapView( @@ -84,7 +85,8 @@ fun StaticMapView( ) } // Case 2: Null location - show blurred placeholder, no pin, no loading - location == null -> { + // Case 3: No map provider configured - show blurred placeholder, no pin, no fetching + location == null || mapTilerConfig == null && !LocalInspectionMode.current -> { StaticMapPlaceholder( painter = painterResource(R.drawable.blurred_map), canReload = false, @@ -94,7 +96,7 @@ fun StaticMapView( onLoadMapClick = {} ) } - // Cases 3 & 4: Non-null location - fetch map + // Cases 4 & 5: Non-null location - fetch map else -> LoadableMapContent( mapTilerConfig = mapTilerConfig, location = location, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt index 9f2c3fa70b..aa14e6505b 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt @@ -8,14 +8,31 @@ package io.element.android.features.location.impl -import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.location.api.BuildConfig +import dev.zacsweers.metro.SingleIn +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.LocationService +import io.element.android.features.location.api.buildTimeMapTilerConfig +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn -@ContributesBinding(AppScope::class) -class DefaultLocationService : LocationService { - override fun isServiceAvailable(): Boolean { - return BuildConfig.MAPTILER_API_KEY.isNotEmpty() - } +@SingleIn(SessionScope::class) +@ContributesBinding(SessionScope::class) +class DefaultLocationService( + customMapTilerConfigProvider: CustomMapTilerConfigProvider, + @SessionCoroutineScope sessionCoroutineScope: CoroutineScope, +) : LocationService { + override val mapTilerConfig: StateFlow = flow { + customMapTilerConfigProvider.get().getOrNull()?.let { emit(it) } + }.stateIn( + scope = sessionCoroutineScope, + started = SharingStarted.Eagerly, + initialValue = buildTimeMapTilerConfig(), + ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index 05af96fe5e..4dbc2fac54 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -22,7 +21,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider +import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.MapDefaults @@ -38,7 +37,6 @@ import io.element.android.features.location.impl.common.userlocation.UserLocatio import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.messages.api.MessageComposerContext import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.core.extensions.flatMap @@ -73,7 +71,7 @@ class ShareLocationPresenter( private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationStore: LiveLocationStore, private val userLocationStateFactory: UserLocationState.Factory, - private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, + private val locationService: LocationService, ) : Presenter { @AssistedFactory fun interface Factory { @@ -94,10 +92,7 @@ class ShareLocationPresenter( var pendingLiveLocationShare by remember { mutableStateOf(false) } val startLiveLocationAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } val currentUser by client.userProfile.collectAsState() - val customMapConfig by produceState(AsyncData.Loading()) { - // Ignore errors - value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull()) - } + val mapTilerConfig by locationService.mapTilerConfig.collectAsState() val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms -> perms.sendLiveLocationPermissions() } @@ -201,7 +196,7 @@ class ShareLocationPresenter( } return ShareLocationState( - customMapTilerConfig = customMapConfig, + mapTilerConfig = mapTilerConfig, currentUser = currentUser, dialogState = dialogState, trackUserLocation = trackUserPosition, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index b29228f866..38747b866f 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -12,12 +12,11 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList data class ShareLocationState( - val customMapTilerConfig: AsyncData, + val mapTilerConfig: MapTilerConfig?, val currentUser: MatrixUser, val dialogState: Dialog, val trackUserLocation: Boolean, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt index 908c7f0641..7a192fc4a8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt @@ -12,8 +12,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState +import io.element.android.features.location.impl.show.aPreviewMapTilerConfig import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.persistentListOf @@ -71,13 +71,13 @@ class ShareLocationStatePreviewParam : PreviewParameterProvider = AsyncData.Success(null), + mapTilerConfig: MapTilerConfig? = aPreviewMapTilerConfig(), currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None, trackUserPosition: Boolean = false, @@ -88,7 +88,7 @@ fun aShareLocationState( eventSink: (ShareLocationEvent) -> Unit = {}, ): ShareLocationState { return ShareLocationState( - customMapTilerConfig = customMapTilerConfig, + mapTilerConfig = mapTilerConfig, currentUser = currentUser, dialogState = dialogState, trackUserLocation = trackUserPosition, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 6baa7bf3d2..d19d3fe0f0 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -130,7 +130,7 @@ private fun ShareLocationViewContent( navigateUp: () -> Unit, modifier: Modifier = Modifier, ) { - val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull()) + val tileStyleUrl = rememberTileStyleUrl(state.mapTilerConfig) val mapState = rememberMapState( baseStyle = BaseStyle.Uri(tileStyleUrl), initialCameraPosition = MapDefaults.defaultCameraPosition, @@ -181,7 +181,7 @@ private fun ShareLocationScaffold( ) ) MapBottomSheetScaffold( - isReady = state.customMapTilerConfig.isReady(), + isReady = state.mapTilerConfig != null, mapState = mapState, modifier = modifier, scaffoldState = scaffoldState, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 51ed1d6c68..0a9911c350 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -22,8 +22,8 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.location.api.Location +import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.api.live.isCurrentlySharing @@ -39,7 +39,6 @@ import io.element.android.features.location.impl.common.toDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState import io.element.android.features.location.impl.common.userlocation.toLocationMeasurement -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.meta.BuildMeta @@ -68,7 +67,7 @@ class ShowLocationPresenter( private val joinedRoom: JoinedRoom, private val liveLocationShareManager: ActiveLiveLocationShareManager, private val userLocationStateFactory: UserLocationState.Factory, - private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, + private val locationService: LocationService, ) : Presenter { @AssistedFactory fun interface Factory { fun create(mode: ShowLocationMode): ShowLocationPresenter @@ -86,10 +85,7 @@ class ShowLocationPresenter( mutableStateOf(LocationConstraintsDialogState.None) } - val customMapTilerConfig by produceState(AsyncData.Loading()) { - // Ignore errors - value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull()) - } + val mapTilerConfig by locationService.mapTilerConfig.collectAsState() fun checkLocationConstraints() { val locationConstraints = checkLocationConstraints( @@ -221,7 +217,7 @@ class ShowLocationPresenter( userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted) } return ShowLocationState( - customMapTilerConfig = customMapTilerConfig, + mapTilerConfig = mapTilerConfig, dialogState = dialogState, locationShares = locationShares, focusedLocation = focusedLocation, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt index 0472eb94e9..79dacdad1f 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -13,7 +13,6 @@ import io.element.android.features.location.api.Location import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationMarkerData import io.element.android.features.location.impl.common.userlocation.UserLocationState -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.UserId @@ -21,7 +20,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType import kotlinx.collections.immutable.ImmutableList data class ShowLocationState( - val customMapTilerConfig: AsyncData, + val mapTilerConfig: MapTilerConfig?, val isLive: Boolean, val dialogState: LocationConstraintsDialogState, val locationShares: ImmutableList, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt index 8473eaeb04..4c106c76a5 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt @@ -13,7 +13,6 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.Location import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE @@ -37,14 +36,21 @@ class ShowLocationStatePreviewParam : PreviewParameterProvider = AsyncData.Success(null), + mapTilerConfig: MapTilerConfig? = aPreviewMapTilerConfig(), isLive: Boolean = false, constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, locationShares: List = listOf(aLocationShareItem(isLive = isLive)), @@ -56,7 +62,7 @@ fun aShowLocationState( eventSink: (ShowLocationEvent) -> Unit = {}, ): ShowLocationState { return ShowLocationState( - customMapTilerConfig = customMapTilerConfig, + mapTilerConfig = mapTilerConfig, dialogState = constraintsDialogState, locationShares = locationShares.toImmutableList(), focusedLocation = focusedLocation, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 8a797ad1a6..483310a78a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -102,7 +102,7 @@ private fun ShowLocationViewContent( onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull()) + val tileStyleUrl = rememberTileStyleUrl(state.mapTilerConfig) val mapState = rememberMapState( baseStyle = BaseStyle.Uri(tileStyleUrl), initialCameraPosition = MapDefaults.defaultCameraPosition, @@ -178,7 +178,7 @@ private fun ShowLocationScaffold( } } MapBottomSheetScaffold( - isReady = state.customMapTilerConfig.isReady(), + isReady = state.mapTilerConfig != null, sheetDragHandle = if (state.isSheetDraggable) { { BottomSheetDefaults.DragHandle() } } else { diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt index b0e899730b..00c7fc89b5 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt @@ -9,15 +9,56 @@ package io.element.android.features.location.impl import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider +import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig import io.element.android.features.location.api.BuildConfig +import io.element.android.features.location.api.buildTimeMapTilerConfig +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class DefaultLocationServiceTest { @Test - fun `isServiceAvailable should return value depending on BuildConfig MAPTILER_API_KEY`() { - val locationService = DefaultLocationService() - assertThat(locationService.isServiceAvailable()).isEqualTo( - BuildConfig.MAPTILER_API_KEY.isNotEmpty() + fun `when the homeserver does not provide a config, the build time config is used`() = runTest { + val locationService = createDefaultLocationService( + customMapTilerConfigProvider = { Result.success(null) }, ) + runCurrent() + assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) + assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty()) } + + @Test + fun `when the homeserver config cannot be retrieved, the build time config is used`() = runTest { + val locationService = createDefaultLocationService( + customMapTilerConfigProvider = { Result.failure(AN_EXCEPTION) }, + ) + runCurrent() + assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) + assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty()) + } + + @Test + fun `when the homeserver provides a config, it is used and the service is available`() = runTest { + val config = aMapTilerConfig(apiKey = "homeserver_api_key") + val locationService = createDefaultLocationService( + customMapTilerConfigProvider = { Result.success(config) }, + ) + runCurrent() + assertThat(locationService.mapTilerConfig.value).isEqualTo(config) + assertThat(locationService.isServiceAvailable().value).isTrue() + } + + private fun TestScope.createDefaultLocationService( + customMapTilerConfigProvider: CustomMapTilerConfigProvider, + sessionCoroutineScope: CoroutineScope = backgroundScope, + ) = DefaultLocationService( + customMapTilerConfigProvider = customMapTilerConfigProvider, + sessionCoroutineScope = sessionCoroutineScope, + ) } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt index a37011e1d9..e57fafa61b 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt @@ -16,6 +16,7 @@ import io.element.android.features.location.impl.common.actions.FakeLocationActi import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.location.test.FakeActiveLiveLocationShareManager +import io.element.android.features.location.test.FakeLocationService import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.matrix.api.timeline.Timeline @@ -58,7 +59,7 @@ class DefaultShareLocationEntryPointTest { sessionId = room.sessionId, ), userLocationStateFactory = FakeUserLocationStateFactory(), - customMapTilerConfigProvider = { Result.success(null) }, + locationService = FakeLocationService(), ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 129ea1e4cd..3dcb358cf1 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -19,9 +19,9 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig import io.element.android.features.location.api.Location +import io.element.android.features.location.api.LocationService import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.FakeUserLocationStateFactory import io.element.android.features.location.impl.common.actions.FakeLocationActions @@ -31,6 +31,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.location.test.FakeActiveLiveLocationShareManager +import io.element.android.features.location.test.FakeLocationService import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.matrix.api.core.EventId @@ -89,7 +90,7 @@ class ShareLocationPresenterTest { liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId), client: FakeMatrixClient = fakeMatrixClient, - customMapTilerConfigProvider: () -> Result = { Result.success(null) }, + locationService: LocationService = FakeLocationService(mapTilerConfig = aMapTilerConfig()), ): ShareLocationPresenter = ShareLocationPresenter( permissionsPresenterFactory = { fakePermissionsPresenter }, room = joinedRoom, @@ -103,7 +104,7 @@ class ShareLocationPresenterTest { liveLocationShareManager = liveLocationShareManager, liveLocationStore = liveLocationStore, userLocationStateFactory = FakeUserLocationStateFactory(), - customMapTilerConfigProvider = customMapTilerConfigProvider, + locationService = locationService, ) @Test @@ -118,26 +119,24 @@ class ShareLocationPresenterTest { val shareLocationPresenter = createShareLocationPresenter() shareLocationPresenter.test { val state = awaitFirstItem() - assertThat(state.customMapTilerConfig.isLoading()).isFalse() + assertThat(state.mapTilerConfig).isNotNull() assertThat(state.trackUserLocation).isTrue() assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None)) } } @Test - fun `present - non-null customMapStyleUrl`() = runTest { + fun `present - mapTilerConfig is provided by the location service`() = runTest { val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY") val shareLocationPresenter = createShareLocationPresenter( client = FakeMatrixClient( sessionId = A_USER_ID, ), - customMapTilerConfigProvider = { Result.success(mapTilerConfig) } + locationService = FakeLocationService(mapTilerConfig = mapTilerConfig), ) shareLocationPresenter.test { - val state = awaitItem() - assertThat(state.customMapTilerConfig.isLoading()).isTrue() - val finalState = awaitItem() - assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig) + assertThat(awaitItem().mapTilerConfig).isEqualTo(mapTilerConfig) + cancelAndIgnoreRemainingEvents() } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt index db5c6b9f9f..83172d1079 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -18,6 +18,7 @@ import io.element.android.features.location.impl.common.FakeUserLocationStateFac import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.test.FakeActiveLiveLocationShareManager +import io.element.android.features.location.test.FakeLocationService import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.core.aBuildMeta @@ -51,7 +52,7 @@ class DefaultShowLocationEntryPointTest { joinedRoom = joinedRoom, liveLocationShareManager = FakeActiveLiveLocationShareManager(), userLocationStateFactory = FakeUserLocationStateFactory(), - customMapTilerConfigProvider = { Result.success(null) }, + locationService = FakeLocationService(), ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index b2a84d24e1..ab1a1af734 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -12,9 +12,9 @@ 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.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig import io.element.android.features.location.api.Location +import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.FakeUserLocationStateFactory @@ -24,6 +24,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.test.FakeActiveLiveLocationShareManager +import io.element.android.features.location.test.FakeLocationService import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom @@ -66,7 +67,7 @@ class ShowLocationPresenterTest { locationActions: FakeLocationActions = fakeLocationActions, joinedRoom: JoinedRoom = FakeJoinedRoom(), liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), - customMapTilerConfigProvider: CustomMapTilerConfigProvider = { Result.success(null) }, + locationService: LocationService = FakeLocationService(mapTilerConfig = aMapTilerConfig()), ) = ShowLocationPresenter( mode = mode, permissionsPresenterFactory = { fakePermissionsPresenter }, @@ -77,18 +78,16 @@ class ShowLocationPresenterTest { joinedRoom = joinedRoom, liveLocationShareManager = liveLocationShareManager, userLocationStateFactory = FakeUserLocationStateFactory(), - customMapTilerConfigProvider = customMapTilerConfigProvider, + locationService = locationService, ) @Test - fun `present - non-null customMapStyleUrl`() = runTest { + fun `present - mapTilerConfig is provided by the location service`() = runTest { val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY") - val shareLocationPresenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(mapTilerConfig) }) - shareLocationPresenter.test { - val state = awaitItem() - assertThat(state.customMapTilerConfig.isLoading()).isTrue() - val finalState = awaitItem() - assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig) + val showLocationPresenter = createShowLocationPresenter(locationService = FakeLocationService(mapTilerConfig = mapTilerConfig)) + showLocationPresenter.test { + assertThat(awaitItem().mapTilerConfig).isEqualTo(mapTilerConfig) + cancelAndIgnoreRemainingEvents() } } @@ -120,7 +119,7 @@ class ShowLocationPresenterTest { fun `centers on user location`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) - val presenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(aMapTilerConfig()) }) + val presenter = createShowLocationPresenter() presenter.test { val initialState = awaitItem() @@ -407,9 +406,6 @@ class ShowLocationPresenterTest { val initialState = awaitItem() assertThat(initialState.locationShares).isEmpty() - // Wait until we receive the custom map style URL result - skipItems(1) - // Emit a new live share liveSharesFlow.value = listOf( aLiveLocationShare(userId = userId) diff --git a/features/location/test/build.gradle.kts b/features/location/test/build.gradle.kts index e51737d40c..e08b702f46 100644 --- a/features/location/test/build.gradle.kts +++ b/features/location/test/build.gradle.kts @@ -16,6 +16,7 @@ android { dependencies { api(projects.features.location.api) + implementation(projects.features.enterprise.api) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) diff --git a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt index f961a81c9b..0ff1545131 100644 --- a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt +++ b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt @@ -8,10 +8,21 @@ package io.element.android.features.location.test +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.LocationService +import kotlinx.coroutines.flow.MutableStateFlow class FakeLocationService( - private val isServiceAvailable: Boolean = false, + mapTilerConfig: MapTilerConfig? = null, ) : LocationService { - override fun isServiceAvailable() = isServiceAvailable + constructor(isServiceAvailable: Boolean) : this( + MapTilerConfig( + apiKey = "test_api_key", + lightStyleId = null, + darkStyleId = null, + baseUrl = null, + ).takeIf { isServiceAvailable } + ) + + override val mapTilerConfig = MutableStateFlow(mapTilerConfig) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 503d30c6d9..c81691b004 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -776,7 +776,7 @@ class MessagesFlowNode( assetType = event.content.assetType, ) } - NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() } + NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable().value } } else { displayVulkanNotSupportedError = true null diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 2974b99696..f0879d962e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -184,10 +184,7 @@ class MessageComposerPresenter( val cameraPermissionState = cameraPermissionPresenter.present() - val canShareLocation = remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - canShareLocation.value = locationService.isServiceAvailable() - } + val canShareLocation by remember { locationService.isServiceAvailable() }.collectAsState() val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages) .collectAsState(initial = false) @@ -431,7 +428,7 @@ class MessageComposerPresenter( isInThreadTimeline = isInThread, showAttachmentSourcePicker = showAttachmentSourcePicker, showTextFormatting = showTextFormatting, - canShareLocation = canShareLocation.value, + canShareLocation = canShareLocation, suggestions = suggestions.toImmutableList(), resolveMentionDisplay = resolveMentionDisplay, resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay, diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index c137d6be06..277d78b50c 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { implementation(projects.features.analytics.api) implementation(projects.features.enterprise.api) implementation(projects.features.licenses.api) + implementation(projects.features.location.api) implementation(projects.features.logout.api) implementation(projects.features.deactivation.api) implementation(projects.features.invite.api) @@ -109,6 +110,7 @@ dependencies { testImplementation(projects.features.enterprise.test) testImplementation(projects.features.invite.test) testImplementation(projects.features.licenses.test) + testImplementation(projects.features.location.test) testImplementation(projects.features.lockscreen.test) testImplementation(projects.features.logout.test) testImplementation(projects.libraries.indicator.test) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 53234c3723..2bd55397c5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope import dev.zacsweers.metro.Inject import io.element.android.compound.theme.Theme import io.element.android.compound.theme.mapToTheme +import io.element.android.features.location.api.LocationService import io.element.android.features.preferences.impl.account.PreferencesAccountState import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider @@ -47,6 +48,7 @@ class PreferencesRootPresenter( private val snackbarDispatcher: SnackbarDispatcher, private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider, private val featureFlagService: FeatureFlagService, + private val locationService: LocationService, private val sessionStore: SessionStore, private val appPreferencesStore: AppPreferencesStore, private val userStatusPresenter: Presenter, @@ -115,6 +117,8 @@ class PreferencesRootPresenter( val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState() + val isLocationServiceAvailable by remember { locationService.isServiceAvailable() }.collectAsState() + fun handleEvent(event: PreferencesRootEvent) { when (event) { is PreferencesRootEvent.OnVersionInfoClick -> { @@ -150,6 +154,7 @@ class PreferencesRootPresenter( showAnalyticsSettings = hasAnalyticsProviders, showDeveloperSettings = showDeveloperSettings, showLabsItem = showLabsItem, + showLocationSettings = isLocationServiceAvailable, snackbarMessage = snackbarMessage, eventSink = ::handleEvent, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index 67b75a84a8..b51d83592f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -32,6 +32,7 @@ data class PreferencesRootState( val showAnalyticsSettings: Boolean, val showDeveloperSettings: Boolean, val showLabsItem: Boolean, + val showLocationSettings: Boolean, val snackbarMessage: SnackbarMessage?, val eventSink: (PreferencesRootEvent) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt index 57b7493dd5..1bbd9e0548 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt @@ -53,6 +53,8 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider Unit = {}, @@ -83,6 +86,7 @@ fun aPreferencesRootState( showAnalyticsSettings = showAnalyticsSettings, showDeveloperSettings = showDeveloperSettings, showLabsItem = showLabsItem, + showLocationSettings = showLocationSettings, userStatusState = userStatusState, snackbarMessage = snackbarMessage, eventSink = eventSink, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 1449ece700..38c02f04e1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -330,11 +330,13 @@ private fun AppSettingsSection( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), onClick = onOpenLockScreenSettings, ) - ListItem( - content = { Text(stringResource(id = CommonStrings.common_location_sharing)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())), - onClick = onOpenLocationSettings, - ) + if (state.showLocationSettings) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_location_sharing)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())), + onClick = onOpenLocationSettings, + ) + } } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 085b74e323..34f60cbae3 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -13,6 +13,8 @@ package io.element.android.features.preferences.impl.root import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import io.element.android.compound.theme.Theme +import io.element.android.features.location.api.LocationService +import io.element.android.features.location.test.FakeLocationService import io.element.android.features.preferences.impl.account.aPreferencesAccountState import io.element.android.features.preferences.impl.userstatus.aUserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider @@ -78,10 +80,21 @@ class PreferencesRootPresenterTest { assertThat(loadedState.showAnalyticsSettings).isFalse() assertThat(loadedState.showDeveloperSettings).isTrue() assertThat(loadedState.showLabsItem).isFalse() + assertThat(loadedState.showLocationSettings).isTrue() assertThat(loadedState.snackbarMessage).isNull() } } + @Test + fun `present - location settings are hidden when the location service is not available`() = runTest { + createPreferencesRootPresenter( + locationService = FakeLocationService(isServiceAvailable = false), + ).test { + assertThat(awaitItem().showLocationSettings).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - user status state is null when the server does not support it`() = runTest { createPreferencesRootPresenter( @@ -291,6 +304,7 @@ class PreferencesRootPresenterTest { featureFlagService: FeatureFlagService = FakeFeatureFlagService(), sessionStore: SessionStore = InMemorySessionStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + locationService: LocationService = FakeLocationService(isServiceAvailable = true), ) = PreferencesRootPresenter( matrixClient = matrixClient, analyticsService = FakeAnalyticsService(), @@ -298,6 +312,7 @@ class PreferencesRootPresenterTest { snackbarDispatcher = SnackbarDispatcher(), showDeveloperSettingsProvider = showDeveloperSettingsProvider, featureFlagService = featureFlagService, + locationService = locationService, sessionStore = sessionStore, appPreferencesStore = appPreferencesStore, userStatusPresenter = { aUserStatusState() }, From b84cf2aceddf3400d3e42738a355fa4b09159c98 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:56:01 +0000 Subject: [PATCH 16/33] Update sqldelight to v2.4.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c83ab600ab..cf80c35602 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ coil = "3.6.3" # There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs. # When upgrading this version, check state restoration still works fine. appyx = "1.7.1" -sqldelight = "2.4.0" +sqldelight = "2.4.1" wysiwyg = "2.42.1" telephoto = "0.19.0" maplibre-compose = "0.19.0" From cff4562b793352bf45925ea767a3226eed0d4868 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 13 Oct 2026 18:15:31 +0000 Subject: [PATCH 17/33] Update screenshots --- ...ures.preferences.impl.root_PreferencesRootViewDark_7_en.png | 3 +++ ...res.preferences.impl.root_PreferencesRootViewLight_7_en.png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_7_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_7_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_7_en.png new file mode 100644 index 0000000000..918410e681 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5316814d8343fece0a09899382353aeb6284a9dc8dab3142242e7bcd58345950 +size 31866 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_7_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_7_en.png new file mode 100644 index 0000000000..2827c76f3c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84bced32601b0f128981d69f841ed3173befb62b34d67317f94c39e153c76191 +size 33108 From 213ed22cadc782a0ac781235fc545c8b2a999ec9 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 13 Oct 2026 22:28:22 +0200 Subject: [PATCH 18/33] Bump SDK bindings to `26.10.12-nightly` (#7909) * Bump SDK bindings to `26.10.12-nightly` * Fix API breaks in SDK bindings * Fix doc comment --- gradle/libs.versions.toml | 2 +- .../libraries/matrix/api/MatrixClient.kt | 2 +- .../api/encryption/EncryptionService.kt | 5 +++ .../libraries/matrix/impl/RustMatrixClient.kt | 33 +++---------------- .../encryption/RustIdentityResetHandle.kt | 14 +++++--- .../encryption/FakeIdentityResetHandle.kt | 2 ++ 6 files changed, 22 insertions(+), 36 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c83ab600ab..cbb6f4ee20 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -186,7 +186,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # it as a plain `require`: without this, a library built against a newer SDK would silently drag this app # onto it. With it, this pin always wins, and a library that needs something newer fails at configuration # time naming both versions instead of at runtime with NoSuchMethodError. -matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.10.07-nightly" } } +matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.10.12-nightly" } } rustls_platform_verifier = "org.rustls:rustls-platform-verifier:0.2.0" # Others diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 71342d1b51..51579a88d4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -410,7 +410,7 @@ interface MatrixClient : ClientUrlContentFetcher { /** * Deactivates the account on the homeserver, then destroys this client and deletes every local trace of the session. - * The password is only sent if the homeserver asks for re-authentication, and on failure the session is left untouched. + * On failure the session is left untouched. * * @param password the account password, required to confirm the deactivation. * @param eraseData whether the homeserver should also erase the messages the user sent. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index e036a198df..40f6ef20a2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -123,6 +123,11 @@ interface EncryptionService { * A handle to reset the user's identity. */ sealed interface IdentityResetHandle { + /** + * The value of the session key given by the homeserver. + */ + val session: String + /** * Cancel the reset process and drops the existing handle in the SDK. */ diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index dcf75b78bc..94be2d3764 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -127,8 +127,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -import org.matrix.rustcomponents.sdk.AuthData -import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails import org.matrix.rustcomponents.sdk.BeaconInfoListener import org.matrix.rustcomponents.sdk.BeaconInfoUpdate import org.matrix.rustcomponents.sdk.Client @@ -794,33 +792,10 @@ class RustMatrixClient( clientDelegateTaskHandle?.cancelAndDestroy() clientDelegateTaskHandle = null runCatchingExceptions { - // First call without AuthData, should fail - val firstAttempt = runCatchingExceptions { - innerClient.deactivateAccount( - authData = null, - eraseData = eraseData, - ) - } - if (firstAttempt.isFailure) { - Timber.w(firstAttempt.exceptionOrNull(), "Expected failure, try again") - // This is expected, try again with the password - runCatchingExceptions { - innerClient.deactivateAccount( - authData = AuthData.Password( - passwordDetails = AuthDataPasswordDetails( - identifier = sessionId.value, - password = password, - ), - ), - eraseData = eraseData, - ) - }.onFailure { - Timber.e(it, "Failed to deactivate account") - // If the deactivation failed we need to restore the delegate - clientDelegateTaskHandle = innerClient.setDelegate(sessionDelegate) - throw it - } - } + innerClient.deactivateAccount( + password = password, + eraseData = eraseData, + ) destroy() deleteSessionDirectory() sessionStore.removeSession(sessionId.value) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt index 7ee9c7d0b3..6bbc8887d4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt @@ -20,14 +20,14 @@ import org.matrix.rustcomponents.sdk.CrossSigningResetAuthType object RustIdentityResetHandleFactory { fun create( userId: UserId, - identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle? + identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle?, ): Result { return runCatchingExceptions { identityResetHandle?.let { when (val authType = identityResetHandle.authType()) { - is CrossSigningResetAuthType.OAuth -> RustIdentityOAuthResetHandle(identityResetHandle, authType.info.approvalUrl) + is CrossSigningResetAuthType.OAuth -> RustIdentityOAuthResetHandle(identityResetHandle, authType.info.approvalUrl, authType.info.session) // User interactive authentication (user + password) - CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle) + is CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle, authType.info.session) } } } @@ -37,9 +37,12 @@ object RustIdentityResetHandleFactory { class RustPasswordIdentityResetHandle( private val userId: UserId, private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle, + override val session: String, ) : IdentityPasswordResetHandle { override suspend fun resetPassword(password: String): Result { - return runCatchingExceptions { identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password))) } + return runCatchingExceptions { + identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password, session))) + } } override suspend fun cancel() { @@ -50,9 +53,10 @@ class RustPasswordIdentityResetHandle( class RustIdentityOAuthResetHandle( private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle, override val url: String, + override val session: String, ) : IdentityOAuthResetHandle { override suspend fun resetOAuth(): Result { - return runCatchingExceptions { identityResetHandle.reset(null) } + return runCatchingExceptions { identityResetHandle.reset(AuthData.OAuth(session)) } } override suspend fun cancel() { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt index d65328d7fe..4248bc62d4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt @@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetH class FakeIdentityOAuthResetHandle( override val url: String = "", + override val session: String = "", var resetOAuthLambda: () -> Result = { error("Not implemented") }, var cancelLambda: () -> Unit = { error("Not implemented") }, ) : IdentityOAuthResetHandle { @@ -26,6 +27,7 @@ class FakeIdentityOAuthResetHandle( } class FakeIdentityPasswordResetHandle( + override val session: String = "", var resetPasswordLambda: (String) -> Result = { _ -> error("Not implemented") }, var cancelLambda: () -> Unit = { error("Not implemented") }, ) : IdentityPasswordResetHandle { From 9b3a9a0a2dff0db60720d7bb6be732a81c00b2de Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:49:35 +0200 Subject: [PATCH 19/33] Update dependency io.element.android:element-call-embedded to v0.28.0 (#7912) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cbb6f4ee20..b29bdb425e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -239,7 +239,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0" metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" } # Element Call -element_call_embedded = "io.element.android:element-call-embedded:0.27.0" +element_call_embedded = "io.element.android:element-call-embedded:0.28.0" # The native MatrixRTC call component. Versions come from the BOM, so every artifact moves together. element_call_bom = { module = "io.element.android:element-call-bom", version.ref = "element_call" } element_call_api = { module = "io.element.android:element-call-api" } From 0149e6f65b86e28f6c608270b46f0826e84c291c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 10:11:07 +0200 Subject: [PATCH 20/33] Do not mix the homeserver MapTiler config with the build time one The map URL builders now only use the MapTilerConfig they are given, falling back to the public MapTiler defaults for the missing values, instead of the values defined at build time. This avoids for instance sending the build time API key to a tile server provided by the homeserver. When there is no config, no static map URL builder is created, so no request is performed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/location/api/StaticMapView.kt | 6 +- .../location/api/internal/MapTilerDefaults.kt | 33 ++++++++++ .../internal/MapTilerStaticMapUrlBuilder.kt | 16 ++--- .../MapTilerTileServerStyleUriBuilder.kt | 27 ++------ .../api/internal/StaticMapUrlBuilder.kt | 10 +-- .../api/internal/TileServerStyleUriBuilder.kt | 19 +++--- .../MapTilerStaticMapUrlBuilderTest.kt | 33 ++++++++++ .../MapTilerTileServerStyleUriBuilderTest.kt | 61 ++++++++----------- 8 files changed, 118 insertions(+), 87 deletions(-) create mode 100644 features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index f6a70f6653..2e8e8b5a60 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -152,8 +152,8 @@ private fun BoxWithConstraintsScope.LoadableMapContent( Triple(painter, state, ContentScale.Crop) } else { val painter = rememberAsyncImagePainter( - model = if (constraints.isZero) { - // Avoid building a URL if any of the size constraints is zero + model = if (builder == null || constraints.isZero) { + // Avoid building a URL if there is no map provider or if any of the size constraints is zero null } else { ImageRequest.Builder(context) @@ -195,7 +195,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent( else -> { StaticMapPlaceholder( painter = painterResource(R.drawable.blurred_map), - canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error, + canReload = builder?.isServiceAvailable() == true && state is AsyncImagePainter.State.Error, contentDescription = contentDescription, width = maxWidth, height = maxHeight, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt new file mode 100644 index 0000000000..509278b3dd --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt @@ -0,0 +1,33 @@ +/* + * 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.location.api.internal + +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig + +/** + * Public MapTiler defaults, used when a [MapTilerConfig] does not define a value. + * Values from another configuration (e.g. the build time one) must never be used as a fallback, + * to avoid sending an API key to a server it was not meant for. + */ +internal object MapTilerDefaults { + const val BASE_URL = "https://api.maptiler.com/maps" + const val LIGHT_MAP_ID = "basic-v2" + const val DARK_MAP_ID = "basic-v2-dark" +} + +internal fun MapTilerConfig.baseUrlOrDefault(): String { + return baseUrl?.takeIf { it.isNotBlank() }?.removeSuffix("/") ?: MapTilerDefaults.BASE_URL +} + +internal fun MapTilerConfig.lightMapIdOrDefault(): String { + return lightStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.LIGHT_MAP_ID +} + +internal fun MapTilerConfig.darkMapIdOrDefault(): String { + return darkStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.DARK_MAP_ID +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt index cd339f67b0..c19d305f3d 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt @@ -9,7 +9,6 @@ package io.element.android.features.location.api.internal import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig -import io.element.android.features.location.api.BuildConfig import kotlin.math.roundToInt /** @@ -23,18 +22,11 @@ internal class MapTilerStaticMapUrlBuilder( private val lightMapId: String, private val darkMapId: String, ) : StaticMapUrlBuilder { - constructor() : this( - baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"), - apiKey = BuildConfig.MAPTILER_API_KEY, - lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID, - ) - constructor(mapTilerConfig: MapTilerConfig) : this( - baseUrl = (mapTilerConfig.baseUrl ?: BuildConfig.MAPTILER_BASE_URL).removeSuffix("/"), - apiKey = mapTilerConfig.apiKey.takeIf { it.isNotBlank() } ?: BuildConfig.MAPTILER_API_KEY, - lightMapId = mapTilerConfig.lightStyleId ?: BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkMapId = mapTilerConfig.darkStyleId ?: BuildConfig.MAPTILER_DARK_MAP_ID, + baseUrl = mapTilerConfig.baseUrlOrDefault(), + apiKey = mapTilerConfig.apiKey, + lightMapId = mapTilerConfig.lightMapIdOrDefault(), + darkMapId = mapTilerConfig.darkMapIdOrDefault(), ) override fun build( diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt index d817a3307f..fc7ddc3fe0 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt @@ -11,33 +11,18 @@ package io.element.android.features.location.api.internal import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig -import io.element.android.features.location.api.BuildConfig - -internal class MapTilerTileServerStyleUriBuilder( - private val baseUrl: String, - private val apiKey: String, - private val lightMapId: String, - private val darkMapId: String, -) : TileServerStyleUriBuilder { - constructor() : this( - baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"), - apiKey = BuildConfig.MAPTILER_API_KEY, - lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID, - ) +internal class MapTilerTileServerStyleUriBuilder : TileServerStyleUriBuilder { override fun build( - customMapTilerConfig: MapTilerConfig?, + mapTilerConfig: MapTilerConfig, darkMode: Boolean, ): String { - val baseUrl = customMapTilerConfig?.baseUrl.takeIf { !it.isNullOrBlank() } ?: baseUrl - val apiKey = customMapTilerConfig?.apiKey ?: apiKey + val baseUrl = mapTilerConfig.baseUrlOrDefault() val mapId = if (darkMode) { - customMapTilerConfig?.darkStyleId ?: darkMapId + mapTilerConfig.darkMapIdOrDefault() } else { - customMapTilerConfig?.lightStyleId ?: lightMapId + mapTilerConfig.lightMapIdOrDefault() } - - return "$baseUrl/$mapId/style.json?key=$apiKey" + return "$baseUrl/$mapId/style.json?key=${mapTilerConfig.apiKey}" } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt index 31992b1dd6..553a4ed160 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt @@ -36,17 +36,13 @@ interface StaticMapUrlBuilder { density: Float, ): String - /** Whether a map provider is configured for this build; the URLs are unusable when it is not. */ + /** Whether an API key is configured; the URLs are unusable when it is not. */ fun isServiceAvailable(): Boolean } @Composable -fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder { +fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder? { return remember(mapTilerConfig) { - if (mapTilerConfig != null) { - MapTilerStaticMapUrlBuilder(mapTilerConfig) - } else { - MapTilerStaticMapUrlBuilder() - } + mapTilerConfig?.let { MapTilerStaticMapUrlBuilder(it) } } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt index 67936525e5..6195f8fad3 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt @@ -22,11 +22,11 @@ interface TileServerStyleUriBuilder { /** * Builds the style URI the map view loads its tiles from. * - * @param customMapTilerConfig the custom MapTiler configuration received from the server, or null if not available. + * @param mapTilerConfig the MapTiler configuration to use. * @param darkMode whether to request the dark map style. */ fun build( - customMapTilerConfig: MapTilerConfig?, + mapTilerConfig: MapTilerConfig, darkMode: Boolean, ): String } @@ -38,13 +38,16 @@ interface TileServerStyleUriBuilder { */ @Composable fun rememberTileStyleUrl( - customMapTileConfig: MapTilerConfig?, + mapTilerConfig: MapTilerConfig?, ): String { val darkMode = !ElementTheme.isLightTheme - return remember(darkMode, customMapTileConfig) { - MapTilerTileServerStyleUriBuilder().build( - customMapTilerConfig = customMapTileConfig, - darkMode = darkMode, - ) + return remember(darkMode, mapTilerConfig) { + // Without config, the map is not displayed, so the style URL is not used. + mapTilerConfig?.let { + MapTilerTileServerStyleUriBuilder().build( + mapTilerConfig = it, + darkMode = darkMode, + ) + }.orEmpty() } } diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt index 3d17107104..c46b7a41d5 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.api.internal import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import org.junit.Test class MapTilerStaticMapUrlBuilderTest { @@ -24,6 +25,38 @@ class MapTilerStaticMapUrlBuilderTest { assertThat(builder.isServiceAvailable()).isTrue() } + @Test + fun `builder created from a config only uses the config values`() { + val configBuilder = MapTilerStaticMapUrlBuilder( + MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = "light", + darkStyleId = "dark", + baseUrl = "https://custom.url/maps/", + ) + ) + assertThat(configBuilder.isServiceAvailable()).isTrue() + assertThat( + configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = true, width = 800, height = 600, density = 1f) + ).isEqualTo("https://custom.url/maps/dark/static/-4.56,1.23,7.8/800x600.webp?key=API_KEY&attribution=topright") + } + + @Test + fun `builder created from a partial config falls back to the public MapTiler defaults`() { + val configBuilder = MapTilerStaticMapUrlBuilder( + MapTilerConfig( + apiKey = "", + lightStyleId = null, + darkStyleId = null, + baseUrl = null, + ) + ) + assertThat(configBuilder.isServiceAvailable()).isFalse() + assertThat( + configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = false, width = 800, height = 600, density = 1f) + ).isEqualTo("https://api.maptiler.com/maps/basic-v2/static/-4.56,1.23,7.8/800x600.webp?key=&attribution=topright") + } + @Test fun `isServiceAvailable returns false if api key is empty`() { val builderWithoutKey = MapTilerStaticMapUrlBuilder( diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt index c153ab1ce4..12c3a7ec63 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt @@ -13,75 +13,64 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import org.junit.Test class MapTilerTileServerStyleUriBuilderTest { - private val builder = MapTilerTileServerStyleUriBuilder( - baseUrl = "https://base.url", - apiKey = "anApiKey", - lightMapId = "aLightMapId", - darkMapId = "aDarkMapId", + private val builder = MapTilerTileServerStyleUriBuilder() + + private val config = MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = "light", + darkStyleId = "dark", + baseUrl = "https://custom.url/maps/", ) @Test fun `light map uri`() { assertThat( builder.build( - customMapTilerConfig = null, + mapTilerConfig = config, darkMode = false, ) - ).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey") + ).isEqualTo("https://custom.url/maps/light/style.json?key=API_KEY") } @Test fun `dark map uri`() { assertThat( builder.build( - customMapTilerConfig = null, + mapTilerConfig = config, darkMode = true, ) - ).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey") + ).isEqualTo("https://custom.url/maps/dark/style.json?key=API_KEY") } @Test - fun `custom map uri light`() { + fun `missing values fall back to the public MapTiler defaults`() { + val partialConfig = MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = null, + darkStyleId = " ", + baseUrl = " ", + ) assertThat( builder.build( - customMapTilerConfig = MapTilerConfig( - apiKey = "API_KEY", - lightStyleId = "light", - darkStyleId = "dark", - baseUrl = "https://custom.url/style.json" - ), + mapTilerConfig = partialConfig, darkMode = false, ) - ).isEqualTo("https://custom.url/style.json/light/style.json?key=API_KEY") - } - - @Test - fun `custom map uri dark`() { + ).isEqualTo("https://api.maptiler.com/maps/basic-v2/style.json?key=API_KEY") assertThat( builder.build( - customMapTilerConfig = MapTilerConfig( - apiKey = "API_KEY", - lightStyleId = "light", - darkStyleId = "dark", - baseUrl = "https://custom.url/style.json" - ), + mapTilerConfig = partialConfig, darkMode = true, ) - ).isEqualTo("https://custom.url/style.json/dark/style.json?key=API_KEY") + ).isEqualTo("https://api.maptiler.com/maps/basic-v2-dark/style.json?key=API_KEY") } @Test - fun `blank custom map uri falls back to the built-in style`() { + fun `blank api key is kept as is`() { assertThat( builder.build( - customMapTilerConfig = MapTilerConfig( - apiKey = "API_KEY", - lightStyleId = "light", - darkStyleId = "dark", - baseUrl = " " - ), + mapTilerConfig = config.copy(apiKey = ""), darkMode = false, ) - ).isEqualTo("https://base.url/light/style.json?key=API_KEY") + ).isEqualTo("https://custom.url/maps/light/style.json?key=") } } From 799f60b955b9a8cb2c57b582c23453b32ce59c11 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 10:11:08 +0200 Subject: [PATCH 21/33] Generate null MapTiler BuildConfig values when not provided Remove the default values from the Gradle file, the public MapTiler defaults are now only defined in MapTilerDefaults. buildConfigFieldStr now accepts a null value. Co-Authored-By: Claude Opus 5.5 (1M context) --- features/location/api/build.gradle.kts | 13 ++++--------- .../location/api/LocalMapTilerConfig.kt | 19 +++++++++---------- .../impl/DefaultLocationServiceTest.kt | 5 ++--- .../extension/VariantDimensionExtension.kt | 7 +++++-- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index 36dbfdb139..811c0d89b7 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -26,7 +26,7 @@ android { defaultConfig { buildConfigFieldStr( name = "MAPTILER_BASE_URL", - value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps" + value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL, ) buildConfigFieldStr( name = "MAPTILER_API_KEY", @@ -35,8 +35,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY") ?: readLocalProperty("services.maptiler.apikey") - } - ?: "" + }, ) buildConfigFieldStr( name = "MAPTILER_LIGHT_MAP_ID", @@ -45,9 +44,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID") ?: readLocalProperty("services.maptiler.lightMapId") - } - // fall back to maptiler's default light map. - ?: "basic-v2" + }, ) buildConfigFieldStr( name = "MAPTILER_DARK_MAP_ID", @@ -56,9 +53,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID") ?: readLocalProperty("services.maptiler.darkMapId") - } - // fall back to maptiler's default dark map. - ?: "basic-v2-dark" + }, ) } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt index 00f9d5190c..bcaa5ac49c 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt @@ -19,14 +19,13 @@ val LocalMapTilerConfig = staticCompositionLocalOf { buildTimeMapTilerConfig() } * The [MapTilerConfig] defined at build time, or null if no MapTiler API key has been provided. */ fun buildTimeMapTilerConfig(): MapTilerConfig? { - return BuildConfig.MAPTILER_API_KEY - .takeIf { it.isNotEmpty() } - ?.let { apiKey -> - MapTilerConfig( - apiKey = apiKey, - lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID, - baseUrl = BuildConfig.MAPTILER_BASE_URL - ) - } + // BuildConfig fields are null when not provided at build time. + val apiKey: String? = BuildConfig.MAPTILER_API_KEY + if (apiKey.isNullOrEmpty()) return null + return MapTilerConfig( + apiKey = apiKey, + lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID, + darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID, + baseUrl = BuildConfig.MAPTILER_BASE_URL, + ) } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt index 00c7fc89b5..8a81895e1f 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt @@ -11,7 +11,6 @@ package io.element.android.features.location.impl import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig -import io.element.android.features.location.api.BuildConfig import io.element.android.features.location.api.buildTimeMapTilerConfig import io.element.android.libraries.matrix.test.AN_EXCEPTION import kotlinx.coroutines.CoroutineScope @@ -30,7 +29,7 @@ class DefaultLocationServiceTest { ) runCurrent() assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) - assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty()) + assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null) } @Test @@ -40,7 +39,7 @@ class DefaultLocationServiceTest { ) runCurrent() assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) - assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty()) + assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null) } @Test diff --git a/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt b/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt index ee43b88154..6ae99db5e5 100644 --- a/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt +++ b/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt @@ -10,14 +10,17 @@ package extension import com.android.build.api.dsl.VariantDimension +/** + * Adds a String field to the generated BuildConfig class. If [value] is null, the field will be null. + */ fun VariantDimension.buildConfigFieldStr( name: String, - value: String, + value: String?, ) { buildConfigField( type = "String", name = name, - value = "\"$value\"" + value = value?.let { "\"$it\"" } ?: "null" ) } From 6c1cc4e3f7a05d265a7c23e7c2e7104bfb8ebe08 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 10:37:15 +0200 Subject: [PATCH 22/33] Handle account switch in RootFlowNode instead of in presenters PreferencesRootPresenter and HomePresenter were updating the latest session in the session store directly, relying on RootNavStateFlow to trigger the navigation as a side effect. Changing the current session is app-level navigation, so it is now forwarded through a new `navigateToSession` callback up to RootFlowNode, which performs the session store update. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../appnav/LoggedInAppScopeFlowNode.kt | 6 +++ .../android/appnav/LoggedInFlowNode.kt | 10 +++++ .../io/element/android/appnav/RootFlowNode.kt | 6 +++ .../features/home/api/HomeEntryPoint.kt | 2 + .../android/features/home/impl/HomeEvent.kt | 3 -- .../features/home/impl/HomeFlowNode.kt | 1 + .../features/home/impl/HomePresenter.kt | 3 -- .../android/features/home/impl/HomeView.kt | 10 +++-- .../home/impl/DefaultHomeEntryPointTest.kt | 2 + .../home/impl/roomlist/RoomListViewTest.kt | 1 + .../preferences/api/PreferencesEntryPoint.kt | 2 + .../preferences/impl/PreferencesFlowNode.kt | 5 +++ .../impl/root/PreferencesRootEvent.kt | 3 -- .../impl/root/PreferencesRootNode.kt | 3 ++ .../impl/root/PreferencesRootPresenter.kt | 3 -- .../impl/root/PreferencesRootView.kt | 7 +++- .../impl/DefaultPreferencesEntryPointTest.kt | 2 + .../impl/root/PreferencesRootPresenterTest.kt | 20 ---------- .../impl/root/PreferencesRootViewTest.kt | 37 +++++++++++-------- 19 files changed, 74 insertions(+), 52 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt index ee8398186d..90aaeadefc 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt @@ -33,6 +33,7 @@ import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import kotlinx.parcelize.Parcelize @@ -59,6 +60,7 @@ class LoggedInAppScopeFlowNode( interface Callback : Plugin { fun navigateToBugReport() fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) } private val callback: Callback = callback() @@ -91,6 +93,10 @@ class LoggedInAppScopeFlowNode( override fun navigateToAddAccount() { callback.navigateToAddAccount() } + + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } } return createNode(buildContext, listOf(callback)) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 188346f12a..27d727afa9 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -85,6 +85,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData @@ -169,6 +170,7 @@ class LoggedInFlowNode( interface Callback : Plugin { fun navigateToBugReport() fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) } private val callback: Callback = callback() @@ -356,6 +358,10 @@ class LoggedInFlowNode( override fun navigateToAddAccount() { callback.navigateToAddAccount() } + + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } } homeEntryPoint.createNode( parentNode = this, @@ -448,6 +454,10 @@ class LoggedInFlowNode( callback.navigateToAddAccount() } + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } + override fun navigateToLinkNewDevice() { backstack.push(NavTarget.LinkNewDevice) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 673bdc1388..5fb7996a0e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -416,6 +416,12 @@ class RootFlowNode( override fun navigateToAddAccount() { backstack.push(NavTarget.NotLoggedInFlow(null)) } + + override fun navigateToSession(sessionId: SessionId) { + lifecycleScope.launch { + sessionStore.setLatestSession(sessionId.value) + } + } } val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap) val buildContext = if (savedNavState != null) { diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt index 486a8bafce..81c467246f 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -14,6 +14,7 @@ import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.JoinedRoom interface HomeEntryPoint : FeatureEntryPoint { @@ -33,5 +34,6 @@ interface HomeEntryPoint : FeatureEntryPoint { fun navigateToRoomSettings(roomId: RoomId) fun navigateToBugReport() fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt index 6d1eec3dff..abb0b1b837 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt @@ -8,10 +8,7 @@ package io.element.android.features.home.impl -import io.element.android.libraries.matrix.api.core.SessionId - sealed interface HomeEvent { data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent - data class SwitchToAccount(val sessionId: SessionId) : HomeEvent data object DismissMultiAccountAnnouncement : HomeEvent } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index a481171e97..fda28e0fa0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -222,6 +222,7 @@ class HomeFlowNode( onRoomClick = ::navigateToRoom, onSettingsClick = callback::navigateToSettings, onAddAccountClick = callback::navigateToAddAccount, + onSwitchToAccount = callback::navigateToSession, onStartChatClick = callback::navigateToCreateRoom, onCreateSpaceClick = callback::navigateToCreateSpace, onSetUpRecoveryClick = callback::navigateToSetUpRecovery, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 95f6c229b8..1938e714eb 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -126,9 +126,6 @@ class HomePresenter( is HomeEvent.SelectHomeNavigationBarItem -> { currentHomeNavigationBarItemOrdinal = event.item.ordinal } - is HomeEvent.SwitchToAccount -> coroutineState.launch { - sessionStore.setLatestSession(event.sessionId.value) - } HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch { announcementService.onAnnouncementDismissed(Announcement.MultiAccount) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index c32ed981c0..f9e5809ed5 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -69,6 +69,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -78,6 +79,7 @@ fun HomeView( onRoomClick: (RoomId, EventId?) -> Unit, onSettingsClick: () -> Unit, onAddAccountClick: () -> Unit, + onSwitchToAccount: (SessionId) -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, onStartChatClick: () -> Unit, @@ -130,6 +132,7 @@ fun HomeView( onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() }, onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() }, onMenuActionClick = onMenuActionClick, + onSwitchToAccount = onSwitchToAccount, ) if (state.globalSearchState.isEnabled) { @@ -167,6 +170,7 @@ private fun HomeScaffold( onStartChatClick: () -> Unit, onCreateSpaceClick: () -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit, + onSwitchToAccount: (SessionId) -> Unit, modifier: Modifier = Modifier, ) { fun onRoomClick(room: RoomListRoomSummary) { @@ -213,9 +217,7 @@ private fun HomeScaffold( }, onMenuActionClick = onMenuActionClick, onOpenSettings = onOpenSettings, - onAccountSwitch = { - state.eventSink(HomeEvent.SwitchToAccount(it)) - }, + onAccountSwitch = onSwitchToAccount, scrollBehavior = scrollBehavior, displayFilters = state.displayRoomListFilters, filtersState = roomListState.filtersState, @@ -379,6 +381,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta onRoomClick = { _, _ -> }, onSettingsClick = {}, onAddAccountClick = {}, + onSwitchToAccount = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, @@ -400,6 +403,7 @@ internal fun HomeViewA11yPreview() = ElementPreview { onRoomClick = { _, _ -> }, onSettingsClick = {}, onAddAccountClick = {}, + onSwitchToAccount = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index 643de32ecb..6e962f6fe0 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -14,6 +14,7 @@ import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.services.analytics.test.FakeAnalyticsService @@ -53,6 +54,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { override fun navigateToRoomSettings(roomId: RoomId) = lambdaError() override fun navigateToBugReport() = lambdaError() override fun navigateToAddAccount() = lambdaError() + override fun navigateToSession(sessionId: SessionId) = lambdaError() } val result = entryPoint.createNode( parentNode = parentNode, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index 3cff7ca28b..c6d185550c 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -305,6 +305,7 @@ private fun AndroidComposeUiTest.setRoomListView( onRoomClick = { roomId, _ -> onRoomClick(roomId) }, onSettingsClick = onSettingsClick, onAddAccountClick = onAddAccountClick, + onSwitchToAccount = EnsureNeverCalledWithParam(), onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onStartChatClick = onCreateRoomClick, diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index e7fbe6069f..a54fc98438 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.parcelize.Parcelize interface PreferencesEntryPoint : FeatureEntryPoint { @@ -44,6 +45,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) fun navigateToLinkNewDevice() fun navigateToBugReport() fun navigateToSecureBackup() diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index d9db0219d4..a49053546d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -46,6 +46,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint @@ -141,6 +142,10 @@ class PreferencesFlowNode( callback.navigateToAddAccount() } + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } + override fun navigateToAnalyticsSettings() { backstack.push(NavTarget.AnalyticsSettings) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt index be2c873a3b..6498c2464b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt @@ -8,11 +8,8 @@ package io.element.android.features.preferences.impl.root -import io.element.android.libraries.matrix.api.core.SessionId - sealed interface PreferencesRootEvent { data object OnVersionInfoClick : PreferencesRootEvent - data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent data object ToggleOtherAccountsExpanded : PreferencesRootEvent data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index c4416ad61e..93d6d25cbd 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTa import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.SessionScope import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer +import io.element.android.libraries.matrix.api.core.SessionId @ContributesNode(SessionScope::class) @AssistedInject @@ -35,6 +36,7 @@ class PreferencesRootNode( ) : Node(buildContext, plugins = plugins) { interface Callback : PreferencesAccountCallback, Plugin { fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) fun navigateToAnalyticsSettings() fun navigateToAbout() fun navigateToDeveloperSettings() @@ -72,6 +74,7 @@ class PreferencesRootNode( modifier = modifier, onBackClick = this::navigateUp, onAddAccountClick = callback::navigateToAddAccount, + onSwitchToSession = callback::navigateToSession, onOpenAnalytics = callback::navigateToAnalyticsSettings, onOpenAbout = callback::navigateToAbout, onOpenDeveloperSettings = callback::navigateToDeveloperSettings, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 2bd55397c5..7d12fd8f51 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -124,9 +124,6 @@ class PreferencesRootPresenter( is PreferencesRootEvent.OnVersionInfoClick -> { showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope) } - is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch { - sessionStore.setLatestSession(event.sessionId.value) - } PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch { appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 38c02f04e1..a509dbfbd0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -65,6 +65,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -79,6 +80,7 @@ fun PreferencesRootView( emojiPickerRenderer: EmojiPickerRenderer, onBackClick: () -> Unit, onAddAccountClick: () -> Unit, + onSwitchToSession: (SessionId) -> Unit, onOpenAnalytics: () -> Unit, onOpenLockScreenSettings: () -> Unit, onOpenAbout: () -> Unit, @@ -124,6 +126,7 @@ fun PreferencesRootView( MultiAccountSection( state = state, onAddAccountClick = onAddAccountClick, + onSwitchToSession = onSwitchToSession, ) } HorizontalDivider( @@ -208,6 +211,7 @@ private fun ColumnScope.UserStatusSection( private fun ColumnScope.MultiAccountSection( state: PreferencesRootState, onAddAccountClick: () -> Unit, + onSwitchToSession: (SessionId) -> Unit, ) { HorizontalDivider( thickness = 8.dp, @@ -275,7 +279,7 @@ private fun ColumnScope.MultiAccountSection( MatrixUserRow( modifier = Modifier .clickable { - state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId)) + onSwitchToSession(matrixUser.userId) } .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), matrixUser = matrixUser, @@ -427,6 +431,7 @@ private fun ContentToPreview(state: PreferencesRootState) { emojiPickerRenderer = NoOpEmojiPickerRenderer, onBackClick = {}, onAddAccountClick = {}, + onSwitchToSession = {}, onOpenAnalytics = {}, onOpenDeveloperSettings = {}, onOpenLocationSettings = {}, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt index afc3801c88..80490255a6 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt @@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.mediaviewer.test.FakeFileViewerEntryPoint import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint @@ -52,6 +53,7 @@ class DefaultPreferencesEntryPointTest { } val callback = object : PreferencesEntryPoint.Callback { override fun navigateToAddAccount() = lambdaError() + override fun navigateToSession(sessionId: SessionId) = lambdaError() override fun navigateToLinkNewDevice() = lambdaError() override fun navigateToBugReport() = lambdaError() override fun navigateToSecureBackup() = lambdaError() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 34f60cbae3..45a0f577eb 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -39,8 +39,6 @@ import io.element.android.libraries.sessionstorage.test.aSessionData 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.lambdaRecorder -import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -179,24 +177,6 @@ class PreferencesRootPresenterTest { } } - @Test - fun `present - switch session invoke method on the session store`() = runTest { - val setLatestSessionResult = lambdaRecorder { } - val sessionStore = InMemorySessionStore( - initialList = listOf( - aSessionData(sessionId = A_SESSION_ID.value), - aSessionData(sessionId = A_SESSION_ID_2.value), - ), - setLatestSessionResult = setLatestSessionResult, - ) - createPreferencesRootPresenter(sessionStore = sessionStore).test { - val loadedState = awaitFirstItem() - loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2)) - setLatestSessionResult.assertions().isCalledOnce() - .with(value(A_SESSION_ID_2.value)) - } - } - @Test fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest { createPreferencesRootPresenter( diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index 3afd6cde9d..6b5ba45109 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.preferences.impl.R import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.ui.components.aMatrixUser @@ -69,23 +70,25 @@ class PreferencesRootViewTest : RobolectricTest() { } @Test - fun `clicking on other session sends a SwitchToSession`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setView( - aPreferencesRootState( - isMultiAccountEnabled = true, - isOtherAccountsSectionExpanded = true, - otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", - ) + fun `clicking on other session invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithParam(A_USER_ID_2) { callback -> + setView( + aPreferencesRootState( + isMultiAccountEnabled = true, + isOtherAccountsSectionExpanded = true, + otherSessions = listOf( + aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ) + ), + eventSink = eventsRecorder, ), - eventSink = eventsRecorder, - ), - ) - onNodeWithText("Bob").performClick() - eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2)) + onSwitchToSession = callback, + ) + onNodeWithText("Bob").performClick() + } } @Test @@ -395,6 +398,7 @@ private fun AndroidComposeUiTest.setView( state: PreferencesRootState, onBackClick: () -> Unit = EnsureNeverCalled(), onAddAccountClick: () -> Unit = EnsureNeverCalled(), + onSwitchToSession: (SessionId) -> Unit = EnsureNeverCalledWithParam(), onOpenAnalytics: () -> Unit = EnsureNeverCalled(), onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(), onOpenAbout: () -> Unit = EnsureNeverCalled(), @@ -418,6 +422,7 @@ private fun AndroidComposeUiTest.setView( emojiPickerRenderer = NoOpEmojiPickerRenderer, onBackClick = onBackClick, onAddAccountClick = onAddAccountClick, + onSwitchToSession = onSwitchToSession, onOpenAnalytics = onOpenAnalytics, onOpenLockScreenSettings = onOpenLockScreenSettings, onOpenAbout = onOpenAbout, From 8a5c50d2b8a5f6a2475b314eaeaebe3762c0b81b Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 14 Oct 2026 11:13:14 +0200 Subject: [PATCH 23/33] Fix `File.getSizeOfFiles()` double counting some files (#7907) The `File.length()` docs state: > Returns the length of the file denoted by this abstract pathname. The return value is unspecified if this pathname denotes a directory. On Android, it *seems* to currently have the right behaviour, summing up the size of its children, which made `File.getSizeOfFiles()` double-count the size of some directories. To avoid this, and be able to control the actual behaviour no matter what future Android versions do, let's count those ourselves. --- .../io/element/android/libraries/androidutils/file/File.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt index 7c5f0450e9..9023a8f935 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt @@ -84,5 +84,9 @@ fun File.getSizeOfFiles(): Long { Timber.v("Get size of ${it.absolutePath}") true } - .sumOf { it.length() } + // Only sum the actual size of files, not directories, to avoid double counting or worse, + // since the docs mention that `length()` for directories is not reliable. + // For directories, we can assume a size of 4096 bytes (4 KiB) as a placeholder, + // which is a common block size on many file systems. + .sumOf { if (it.isFile) it.length() else 4096L } } From 241f8e67bd52505765ac0e8036c2b0159b2c802e Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 14 Oct 2026 11:13:59 +0200 Subject: [PATCH 24/33] Fix side-effects when tapping on a link in the new renderer (#7906) The case where we move the pointer once we've started the click on a link wasn't taken into account, so the click or long click gestures weren't canceled if the gesture ended up being a 'move' one. Simplify the handler and make it more idiomatic too. --- .../htmlrenderer/api/ui/HtmlMessageContent.kt | 41 ++++++------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt index e779b5086f..283aa356be 100644 --- a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt @@ -15,6 +15,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -53,8 +55,7 @@ import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate -import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException -import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard @@ -565,34 +566,18 @@ private fun Modifier.linkTapHandler( val (url, urlText) = layoutResult.value?.urlAt(down.position, text) ?: return@awaitEachGesture down.consume() - val longPressTimeout = viewConfiguration.longPressTimeoutMillis - - // Try detecting a long press by waiting for the long press timeout and checking if we received an up event before that. - // If we did, it's a tap, otherwise it's a long press. - // This warning is a false positive. - @Suppress("KotlinConstantConditions") - val isLongPress = try { - withTimeout(longPressTimeout) { - var receivedUp = false - while (!receivedUp) { - val event = awaitPointerEvent() - val up = event.changes.firstOrNull { it.id == down.id && it.changedToUp() } - if (up != null) { - receivedUp = true - up.consume() - } - } - - !receivedUp - } - } catch (_: PointerEventTimeoutCancellationException) { - true - } - - if (isLongPress) { + // Returns a non-null change if the long press timeout elapsed, or null if the gesture was canceled by movement beyond touch slop or by an up event. + if (awaitLongPressOrCancellation(down.id) != null) { context.onLinkLongClick(url, urlText) + // Swallow the rest of the gesture so parents don't react to the eventual release. + waitForUpOrCancellation()?.consume() } else { - context.onLinkClick(url, urlText) + // Either a tap (the pointer was released) or a drag (the user probably wanted to scroll). + val up = currentEvent.changes.firstOrNull { it.id == down.id && it.changedToUpIgnoreConsumed() } + if (up != null) { + up.consume() + context.onLinkClick(url, urlText) + } } } } From 566eb1f4f3af97526359beb85f7414e0402e0c71 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 14 Oct 2026 11:14:38 +0200 Subject: [PATCH 25/33] Use local SDK library for EC native too (#7916) When a local SDK AAR file is present, ignore the version needed by the Element Call Matrix library. This allows us to test local SDK builds. --- libraries/matrix/impl/build.gradle.kts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 9d025fc9a8..91b0b9c5c3 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -59,7 +59,11 @@ dependencies { implementation(platform("io.element.android:element-call-bom:$elementCallVersion")) implementation(libs.element.call.api) implementation(projects.features.callnative.api) - implementation(libs.element.call.matrix) + implementation(libs.element.call.matrix) { + if (usesLocalRustSdk) { + exclude(group = "org.matrix.rustcomponents", module = "sdk-android") + } + } implementation(projects.appconfig) implementation(projects.features.enterprise.api) From f72af72d802f9747b066b8ed87bf339f68bb23e9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 11:35:13 +0200 Subject: [PATCH 26/33] Add a back button to the account provider confirmation screen The screen where the user enters the server address only supported the system back gesture. Add a top app bar with a back button, consistent with the screen to choose the account provider and FlowStepPage, so the user can navigate back from it, especially when adding another account. Part of #7768 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ConfirmAccountProviderNode.kt | 1 + ...ConfirmAccountProviderStatePreviewParam.kt | 2 +- .../ConfirmAccountProviderView.kt | 13 ++++- .../ConfirmAccountProviderViewTest.kt | 57 +++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt index e1a2d85412..e60358c7f5 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt @@ -56,6 +56,7 @@ class ConfirmAccountProviderNode( ConfirmAccountProviderView( state = state, modifier = modifier, + onBackClick = ::navigateUp, onOAuthDetails = callback::navigateToOAuth, onNeedLoginPassword = callback::navigateToLoginPassword, onLearnMoreClick = { openLearnMorePage(context) }, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt index be85a306ba..4657f4c1d7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt @@ -34,7 +34,7 @@ open class ConfirmAccountProviderStatePreviewParam : PreviewParameterProvider Unit, onOAuthDetails: (OAuthDetails) -> Unit, onNeedLoginPassword: () -> Unit, onLearnMoreClick: () -> Unit, @@ -118,9 +122,15 @@ fun ConfirmAccountProviderView( // Let the header and content scroll so the supporting text is not clipped by the footer when the // keyboard is visible and vertical space is reduced. isScrollable = true, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackClick) }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + ) + }, header = { IconTitleSubtitleMolecule( - modifier = Modifier.padding(top = 60.dp), iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), title = stringResource( id = if (state.isAccountCreation) { @@ -272,6 +282,7 @@ internal fun ConfirmAccountProviderViewPreview( ) = ElementPreview { ConfirmAccountProviderView( state = state, + onBackClick = {}, onOAuthDetails = {}, onNeedLoginPassword = {}, onLearnMoreClick = {}, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt new file mode 100644 index 0000000000..61f22a1fec --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt @@ -0,0 +1,57 @@ +/* + * 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.features.login.impl.screens.confirmaccountprovider + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.libraries.matrix.api.auth.OAuthDetails +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class ConfirmAccountProviderViewTest : RobolectricTest() { + @Test + fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { + val eventSink = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setConfirmAccountProviderView( + state = aConfirmAccountProviderState( + eventSink = eventSink, + ), + onBackClick = it, + ) + pressBack() + } + } + + private fun AndroidComposeUiTest.setConfirmAccountProviderView( + state: ConfirmAccountProviderState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(), + onNeedLoginPassword: () -> Unit = EnsureNeverCalled(), + onLearnMoreClick: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + ConfirmAccountProviderView( + state = state, + onBackClick = onBackClick, + onOAuthDetails = onOAuthDetails, + onNeedLoginPassword = onNeedLoginPassword, + onLearnMoreClick = onLearnMoreClick, + ) + } + } +} From 6a516a02aeca03779df7864850f48b70a2e0dc24 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 11:56:28 +0200 Subject: [PATCH 27/33] Iterate on the identity confirmation screen Follow the multi-account design for the "Confirm your digital identity" screen: use the dedicated "Sign out" string for the top bar action and add a leading icon to the "Use another device" button. Add a test to check that clicking on "Sign out" emits the expected event. Part of #7768 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ChooseSelfVerificationModeStatePreviewParam.kt | 3 ++- .../choosemode/ChooseSelfVerificationModeView.kt | 7 ++++++- features/ftue/impl/src/main/res/values/localazy.xml | 1 + .../ChooseSessionVerificationModeViewTest.kt | 11 +++++++++++ .../impl/src/main/res/values/localazy.xml | 1 + 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt index d12a3003ae..34125cb50b 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt @@ -46,11 +46,12 @@ class ChooseSelfVerificationModeStatePreviewParam : fun aChooseSelfVerificationModeState( buttonsState: AsyncData = AsyncData.Success(aButtonsState()), currentUser: MatrixUser = aMatrixUser(), + eventSink: (ChooseSelfVerificationModeEvent) -> Unit = {}, ) = ChooseSelfVerificationModeState( currentUser = currentUser, buttonsState = buttonsState, directLogoutState = aDirectLogoutState(), - eventSink = {}, + eventSink = eventSink, ) fun aButtonsState( diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt index 63d653410f..d58f83200c 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton @@ -45,6 +46,9 @@ import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfo import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState import io.element.android.libraries.ui.strings.CommonStrings +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=432-31813 + */ @Composable fun ChooseSelfVerificationModeView( state: ChooseSelfVerificationModeState, @@ -65,7 +69,7 @@ fun ChooseSelfVerificationModeView( title = {}, actions = { TextButton( - text = stringResource(CommonStrings.action_signout), + text = stringResource(R.string.screen_identity_confirmation_sign_out), onClick = { state.eventSink(ChooseSelfVerificationModeEvent.SignOut) } ) } @@ -141,6 +145,7 @@ private fun ChooseSelfVerificationModeButtons( modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.screen_identity_use_another_device), onClick = onUseAnotherDevice, + leadingIcon = IconSource.Vector(CompoundIcons.Devices()), ) } if (state.buttonsState.data.canUseRecoveryKey) { diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml index 8e208278a0..6cd117eb59 100644 --- a/features/ftue/impl/src/main/res/values/localazy.xml +++ b/features/ftue/impl/src/main/res/values/localazy.xml @@ -2,6 +2,7 @@ "Can\'t confirm?" "Create a new recovery key" + "Sign out" "Choose how to verify to set up secure messaging." "Confirm your digital identity" "Use another device" diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt index 66e2eb38fe..419c8d3371 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt @@ -18,6 +18,7 @@ import io.element.android.features.ftue.impl.R import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.robolectric.RobolectricTest @@ -73,6 +74,16 @@ class ChooseSessionVerificationModeViewTest : RobolectricTest() { } } + @Test + fun `clicking on sign out emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setChooseSelfVerificationModeView( + aChooseSelfVerificationModeState(eventSink = eventsRecorder), + ) + clickOn(R.string.screen_identity_confirmation_sign_out) + eventsRecorder.assertSingle(ChooseSelfVerificationModeEvent.SignOut) + } + private fun AndroidComposeUiTest.setChooseSelfVerificationModeView( state: ChooseSelfVerificationModeState, onLearnMoreClick: () -> Unit = EnsureNeverCalled(), diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml index 012437792e..5803cc2a95 100644 --- a/features/verifysession/impl/src/main/res/values/localazy.xml +++ b/features/verifysession/impl/src/main/res/values/localazy.xml @@ -2,6 +2,7 @@ "Can\'t confirm?" "Create a new recovery key" + "Sign out" "Choose how to verify to set up secure messaging." "Confirm your digital identity" "Use another device" From 933fc54e12c40d9a8a72aaf38bb9ee8968f5cfb4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 12:03:14 +0200 Subject: [PATCH 28/33] Iterate on add account onboarding UI. --- .../login/impl/screens/onboarding/OnBoardingView.kt | 8 ++++++-- features/login/impl/src/main/res/values/localazy.xml | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt index 018d55ab09..59d3863b4f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -180,6 +180,9 @@ private fun AddFirstAccountScaffold( ) } +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=147-34607 + */ @Composable private fun AddOtherAccountScaffold( loginView: @Composable () -> Unit, @@ -189,8 +192,9 @@ private fun AddOtherAccountScaffold( ) { FlowStepPage( modifier = modifier, - title = stringResource(CommonStrings.common_add_account), - iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), + title = stringResource(R.string.screen_onboarding_add_account_title), + subTitle = stringResource(R.string.screen_onboarding_add_account_subtitle), + iconStyle = BigIcon.Style.Default(CompoundIcons.Plus()), buttons = { buttons() }, content = loginView, onBackClick = onBackClick, diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index d77081a70f..0d716537f3 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -52,6 +52,8 @@ "Follow the instructions to enable your key storage" "Come back to %1$s" "Enable your key storage before proceeding to %1$s" + "You can add multiple accounts to the app and switch between them easily." + "Sign in to another account" "Version %1$s" "Checking account" "Sign in manually" From be98d04f4090fa3cb64ac887d9a24f8427fc3f90 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 14 Oct 2026 10:26:25 +0000 Subject: [PATCH 29/33] Update screenshots --- ...ion.choosemode_ChooseSelfVerificationModeView_Day_0_en.png | 4 ++-- ...ion.choosemode_ChooseSelfVerificationModeView_Day_1_en.png | 4 ++-- ...ion.choosemode_ChooseSelfVerificationModeView_Day_2_en.png | 4 ++-- ...ion.choosemode_ChooseSelfVerificationModeView_Day_3_en.png | 4 ++-- ...ion.choosemode_ChooseSelfVerificationModeView_Day_4_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_0_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_1_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_2_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_3_en.png | 4 ++-- ...n.choosemode_ChooseSelfVerificationModeView_Night_4_en.png | 4 ++-- ...irmaccountprovider_ConfirmAccountProviderView_Day_0_en.png | 4 ++-- ...irmaccountprovider_ConfirmAccountProviderView_Day_1_en.png | 4 ++-- ...irmaccountprovider_ConfirmAccountProviderView_Day_2_en.png | 4 ++-- ...irmaccountprovider_ConfirmAccountProviderView_Day_3_en.png | 4 ++-- ...maccountprovider_ConfirmAccountProviderView_Night_0_en.png | 4 ++-- ...maccountprovider_ConfirmAccountProviderView_Night_1_en.png | 4 ++-- ...maccountprovider_ConfirmAccountProviderView_Night_2_en.png | 4 ++-- ...maccountprovider_ConfirmAccountProviderView_Night_3_en.png | 4 ++-- ....login.impl.screens.onboarding_OnBoardingView_Day_7_en.png | 4 ++-- ...ogin.impl.screens.onboarding_OnBoardingView_Night_7_en.png | 4 ++-- 20 files changed, 40 insertions(+), 40 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png index 12adbd61f4..a2fb238031 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6daccb2b17eca0c0720ae731f38f82710839d5c744c9d1a8d60b70e6da41c82a -size 39472 +oid sha256:54394615b9621785f54b1974802f9c2a4808f07abb5f4e4c32a390421e513af3 +size 37567 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png index 8e421af5ee..f34eec28bf 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:25b6ab1e24e67a122eebf2e02d48412a349b85b9b8f2e742b7f1bca5c1146175 -size 34664 +oid sha256:93b596bdc0e7f228709ac9d06f6e183ca398ea55b3d08d95d24f0f5fa7a42be5 +size 32704 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png index 7be0af4b93..460c026aae 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bbb89e5933828be05e39fa7bcebee6eeb34459913e4b999103fd04190184e405 -size 44172 +oid sha256:e8ba95c046cbd87f77a575f4b05e6f8dfe456a3072cea0f7b9d6318302555696 +size 42473 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png index 36a948c665..22ab1da7d7 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fb092bee675333a26b9f2a63bc947c029356808caa65a1f77b317f3b42dc4b49 -size 39596 +oid sha256:ed2510e7bbbd746381d3c6c031adfeef970b4b7002b80ea642df4aa106068aa6 +size 37891 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png index 2052266989..d74e427dd2 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:877604b659b8fa76febe1afb2509bc75d61d665b5f7fdc4bfc863db5e18edcb4 -size 33366 +oid sha256:b436b82845778365f2f419ca4a0da2b856ca7f424153bda1e1904bc675906ea2 +size 31387 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png index e3957acf4d..c2d0e7137d 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:819c5f83d31fe66e64662e0f78f73b46ec0405ac3ef0058e688bc9bda09abfc2 -size 38430 +oid sha256:2c313aba8c31160b759102755059257f082b98abe72ec138fdbdb3c9bde1409c +size 36566 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png index a49f6804b1..6197904ce6 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9079a774c1acb8d925591bd060be5f90e7f7176ada04163507fcaeb508697c8e -size 33802 +oid sha256:3010901ea3daddd0022ebce001f236f1b76946edf922de64c8fb10e3e63e043e +size 31941 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png index ce925ec765..f1c966131d 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c14e2c3308774dd97e5d8e793c53836925d6bd61e27dfe6a0192f6fe2514086c -size 42902 +oid sha256:4d84fb04e7c4444fdaa942d4ebda166f8b76dcf5fdf14ebc8ff0eabdbbb6d850 +size 41302 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png index 4dd0b016ae..a9e7ae415f 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29e50d5d09399a001a88199add0a5ff6974d546d16a127aa699862dd1535de59 -size 38457 +oid sha256:d357c719b4b315da87352e676d9d0d5affff06eff29f8409e2f31dbe76bfec66 +size 36816 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png index 8e90d03e64..6d18750ccb 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:679496d2ce865f83726406b3c3e7c831e48b08e3e42972eb608a5c339319179e -size 32459 +oid sha256:7a9578d6356ee7aab713fc67978aa0a5d74cba5f5094d9f589dc05598925a990 +size 30578 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png index 6037f8376f..3400dba011 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1658bb9275f540a293117d7e387948d0fe2b8ed8c3c61bdba94bf4229bc4403b -size 35906 +oid sha256:366574d116e3fa7a822bcb1e3fb73dadcd4830c8b065c59c020a0c87e4f0b43d +size 35927 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png index f5631fc5b4..7a4b958eab 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5df9a053734bd8e175d28174e9ccb14cdd8403322c727b791b7323adcf1b711e -size 35786 +oid sha256:983e3b175aef6a5ed53609634dc9c8734cee1193fc20f34ed4bd82d78e518de6 +size 35892 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png index f1a3b38270..54a8fa84d4 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73ef55cee2a287ba1ba9f46c0cd902724cd42ac88a36c63195b4d245bd5ac061 -size 39639 +oid sha256:f0bccfc71f3fe376b587128826aa11bba87afa1af2d0736469bcc048a1315607 +size 39566 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png index 1e6c5df990..d709499232 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ecb9ad5d977a95e30dde4bc29352f692fda9f73406785dd9a578906173219460 -size 32518 +oid sha256:703e28ef45cba713b32a65ed73ccd594738bcee7121447b1d4521605e2bfb77d +size 32968 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png index 5ce7683510..283e1d3e71 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a46bc1ee0e63d6785a0c9b4ff739f5694432de9cd49c7db6884bf19eceac545 -size 35040 +oid sha256:283d80c3d4656ab9c9137cd3a5c2730d73850ce57711a464cdb54f8d6f070820 +size 35084 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png index 5a99714595..dacc3ee7b8 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1e1119b78f27ff7c7eac03d7742edf22f73c9e5a7f9c5c8b467c2f2e823da09e -size 34934 +oid sha256:edc5f89c33428fdb6a6fc33d479f9e791dda0554c623360399458d4d2b9521d4 +size 35058 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png index 52597c381d..82b0c9f339 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c9489d83c2ef0f6aeacff1926594ca17784481fa542daa16bbe6bf2749c2122 -size 38823 +oid sha256:2c00a26b76909b5863278c3d9ac001b34630dae9ba39ab47a01c3ec8a3b7e799 +size 38707 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png index e4455a669a..df2d65517d 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0cc47f7a7008742d0239cb3a2e239c38d6a2eb581d49c0cd3f99f0a71f5c9ff -size 30670 +oid sha256:2bdb861840006eb27eedf85b0f344624dc611983d8c91310124d29be0c6e5f40 +size 30827 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png index ff3ef1f7f6..2e4348101d 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e70863623c70a3aed1fc947dfb26f19bc31abf563e793f97cf02f1f15e12bb0 -size 21797 +oid sha256:25447835e040327d9f7e257ab5488912ca140e44e55c43201490955c50a17d37 +size 33651 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png index 71e0095992..c1f37d1503 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76120a9ba1378b8ac2c8963157a6c3d3124114bfef1c5dbac200f7d68bf730db -size 20816 +oid sha256:05eddb1c42f5b7ec5b8dcae7512e17d2ef93f82e6736ec1513df9fe731692435 +size 32379 From 3ef59a891059ca1c9140bbc713328169951bbc94 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 14 Oct 2026 14:04:07 +0200 Subject: [PATCH 30/33] Don't log OkHttp binary bodies in debug builds (#7919) This is mostly done when uploading bug reports and on one hand, it doesn't really make sense since the binary contents will be unreadable anyway, and on the other it also messes up with our debug tools, making debugging some issues harder. --- .../network/interceptors/DynamicHttpLoggingInterceptor.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt index daaf922ce7..025bf606f5 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.network.interceptors import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.preferences.api.store.AppPreferencesStore import kotlinx.coroutines.flow.first @@ -31,7 +32,12 @@ class DynamicHttpLoggingInterceptor( override fun intercept(chain: Interceptor.Chain): Response { // This is called in a separate thread, so calling `runBlocking` here should be fine, it should be also instant after the value is cached val logLevel = runBlocking { appPreferencesStore.getTracingLogLevelFlow().first() } - loggingInterceptor.level = if (logLevel >= LogLevel.DEBUG) Level.BODY else Level.NONE + // If the request body is binary, we log only the headers to avoid logging binary data + val isBinaryBody = chain.request().headers["Content-Type"] == MimeTypes.OctetStream + loggingInterceptor.level = when { + logLevel >= LogLevel.DEBUG -> if (isBinaryBody) Level.HEADERS else Level.BODY + else -> Level.NONE + } return loggingInterceptor.intercept(chain) } } From 1c500374c168734fc1da066664a66257904900fc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:25:19 +0000 Subject: [PATCH 31/33] Update actions/upload-artifact action to v7.0.2 (#7921) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- .github/workflows/build_enterprise.yml | 2 +- .github/workflows/maestro-local.yml | 4 ++-- .github/workflows/nightlyReports.yml | 4 ++-- .github/workflows/quality.yml | 8 ++++---- .github/workflows/release.yml | 4 ++-- .github/workflows/tests.yml | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e997e1cbdd..b1ba9dd010 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,7 +74,7 @@ jobs: run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload debug APKs if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-debug path: | diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml index 294b94a872..589f0a329d 100644 --- a/.github/workflows/build_enterprise.yml +++ b/.github/workflows/build_enterprise.yml @@ -79,7 +79,7 @@ jobs: run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload debug Enterprise APKs if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-enterprise-debug path: | diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index 89413a4d2d..25aa0f6eac 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -63,7 +63,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} - name: Upload APK as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-apk-maestro path: | @@ -138,7 +138,7 @@ jobs: pip install requests python3 ./tools/mas/delete_all_sessions.py --homeserver matrix.org --username maestroelement - name: Upload test results - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-results path: | diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index 88b267acc4..ad55afda66 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -58,7 +58,7 @@ jobs: - name: ✅ Upload kover report if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: kover-results path: | @@ -92,7 +92,7 @@ jobs: run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES - name: Upload dependency analysis if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dependency-analysis path: build/reports/dependency-check-report.html diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 006b0101f6..2efd222fc7 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -120,7 +120,7 @@ jobs: run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: konsist-report path: | @@ -199,7 +199,7 @@ jobs: run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: linting-report path: | @@ -240,7 +240,7 @@ jobs: run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: detekt-report path: | @@ -281,7 +281,7 @@ jobs: run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ktlint-report path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd1bba77f4..316fcc27ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,7 +59,7 @@ jobs: ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }} run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload bundle as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-app-gplay-bundle-unsigned path: | @@ -105,7 +105,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload apks as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-app-fdroid-apks-unsigned path: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b54741c566..816bf1e441 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -83,7 +83,7 @@ jobs: - name: 🚫 Upload kover failed coverage reports if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: kover-error-report path: | @@ -95,7 +95,7 @@ jobs: - name: 🚫 Upload test results on error if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: tests-and-screenshot-tests-results path: | From 2bec8e1487260e8818be533be76f19fafae0befb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 14:46:05 +0200 Subject: [PATCH 32/33] Let Gradle continue after a task failure in CI checks Add --continue to the unit tests, screenshot tests, detekt and ktlint Gradle invocations, so that a failure in one module does not prevent the other modules from being checked, and the CI reports all the failures at once. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/nightlyReports.yml | 4 ++-- .github/workflows/quality.yml | 4 ++-- .github/workflows/tests.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index ad55afda66..5c3e33f577 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -48,10 +48,10 @@ jobs: cache-read-only: false - name: ⚙️ Run unit tests, debug and release - run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES --continue - name: 📸 Run screenshot tests - run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: 📈 Generate kover report and verify coverage run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyAll $CI_GRADLE_ARG_PROPERTIES diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2efd222fc7..1fa443c0f5 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -237,7 +237,7 @@ jobs: with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Detekt - run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon + run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon --continue - name: Upload reports if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -278,7 +278,7 @@ jobs: with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Ktlint check - run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES --continue - name: Upload reports if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 816bf1e441..6222ac024c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -73,10 +73,10 @@ jobs: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: ⚙️ Run unit tests - run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --continue - name: ⚙️ Run screenshot tests - run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: ⚙️ Generate kover coverage report run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES From faa7956b93e3e055247c600b90ada971160af9df Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 14:46:15 +0200 Subject: [PATCH 33/33] Fix flaky NotificationSettingsPresenterTest after loading distributors off the main thread The distributors are now loaded on the IO dispatcher, which was a StandardTestDispatcher in the test, so the initial state could be read before they were loaded. Use an unconfined test dispatcher so that they are loaded deterministically. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../impl/notifications/NotificationSettingsPresenterTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt index e37d39d775..ed65f9a9f3 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -1177,7 +1177,7 @@ class NotificationSettingsPresenterTest { stringProvider = FakeStringProvider(), sessionCoroutineScope = backgroundScope, featureFlagService = featureFlagService, - dispatchers = testCoroutineDispatchers(), + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), ) } }