diff --git a/CHANGES.md b/CHANGES.md
index 47ea7ec332..164739cb60 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,145 @@
+Changes in Element X v26.01.0
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Link new device using QrCode - First version by @bmarty in https://github.com/element-hq/element-x-android/pull/5909
+* Voice message: variable play back speed by @bmarty in https://github.com/element-hq/element-x-android/pull/5963
+* Change Room’s Access to/from Space members by @ganfra in https://github.com/element-hq/element-x-android/pull/5979
+* Create spaces by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5982
+### 🙌 Improvements
+* change(room member): make sure we never display name/avatar when member is banned by @ganfra in https://github.com/element-hq/element-x-android/pull/5826
+* Change : room details edit by @ganfra in https://github.com/element-hq/element-x-android/pull/5844
+* Space feature flags by @ganfra in https://github.com/element-hq/element-x-android/pull/5827
+* Update unsaved change dialog by @bmarty in https://github.com/element-hq/element-x-android/pull/5845
+* change(notification): handle invite notification for spaces by @ganfra in https://github.com/element-hq/element-x-android/pull/5854
+* Change : space settings iteration by @ganfra in https://github.com/element-hq/element-x-android/pull/5908
+* Change : add "settings" entry menu by @ganfra in https://github.com/element-hq/element-x-android/pull/5948
+* Changes : iterate again on permissions by @ganfra in https://github.com/element-hq/element-x-android/pull/5950
+### 🐛 Bugfixes
+* fix: usersWithRole(Owner) returns creators only if privilegedCreatorRole is true by @ganfra in https://github.com/element-hq/element-x-android/pull/5832
+* Limit composer height dynamically by @bmarty in https://github.com/element-hq/element-x-android/pull/5835
+* Fix work requests for inaccessible sessions being re-scheduled indefinitely by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5849
+* Fix permission setting navigation by @bmarty in https://github.com/element-hq/element-x-android/pull/5877
+* URL-encode deep link path segments and decode them when parsing by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5880
+* Fix crash when calling `Room.predecessorRoom` when the room is destroyed by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5894
+* fix: edit moderators not working by @ganfra in https://github.com/element-hq/element-x-android/pull/5906
+* Use the right video preset when sharing videos by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5892
+* Add `threadInfo` field to message like timeline events by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5930
+* Fix unverified account after account creation by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5914
+* Fix class cast exception by @bmarty in https://github.com/element-hq/element-x-android/pull/5958
+* Fix : iterate on unban permissions by @ganfra in https://github.com/element-hq/element-x-android/pull/5959
+* Use `VerificationState.VERIFIED` as soon as it's available by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5973
+* Make the notification silent when the message is an outgoing message by @bmarty in https://github.com/element-hq/element-x-android/pull/5961
+* Remove previously used id filtering from `RoomSyncSubscriber` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5985
+* When handling incoming share, reuse existing room screen if possible by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6001
+* When a duplicate room list entry is found, report it and remove it by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6006
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5860
+* Sync Strings - Adding translations for Croatian by @ElementBot in https://github.com/element-hq/element-x-android/pull/5904
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5946
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5956
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5971
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5994
+### 🧱 Build
+* Restore `no-unused-imports` behaviour for `ktlintFormat` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5847
+* Fix: use the right `BuildTimeConfig` field for the SDK DSN by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5856
+* Add a way to configure value of useLegacyPackaging by @bmarty in https://github.com/element-hq/element-x-android/pull/5862
+* Improve proguard config to keep the names in the classes in our packages by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5882
+* Fix crash when changing the push provider in nightlies by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5951
+### Dependency upgrades
+* fix(deps): update dependency androidx.exifinterface:exifinterface to v1.4.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5846
+* fix(deps): update metro to v0.8.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5833
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5831
+* chore(deps): update plugin sonarqube to v7.2.0.6526 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5851
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5855
+* fix(deps): update dependency io.sentry:sentry-android to v8.28.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5853
+* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5852
+* Update dependency io.mockk:mockk to v1.14.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5866
+* Update metro to v0.8.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5867
+* Update peter-evans/create-pull-request action to v7.0.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5865
+* Update camera to v1.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5857
+* fix(deps): update showkase to v1.0.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5868
+* chore(deps): update codecov/codecov-action action to v5.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5874
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.2.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5876
+* fix(deps): update dependency net.zetetic:sqlcipher-android to v4.12.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5872
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5881
+* Update android.gradle.plugin to v8.13.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5887
+* fix(deps): update dependency com.google.crypto.tink:tink-android to v1.20.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5875
+* Update Compose BOM to version 2025.12.00. by @bmarty in https://github.com/element-hq/element-x-android/pull/5179
+* Sync compound tokens https://github.com/element-hq/compound-design-tokens/releases/tag/v6.4.3 by @bmarty in https://github.com/element-hq/element-x-android/pull/5913
+* fix(deps): update lifecycle to v2.10.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5240
+* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5911
+* fix(deps): update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5417
+* fix(deps): update activity to v1.12.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5770
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.17 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5912
+* fix(deps): update dependency io.sentry:sentry-android to v8.29.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5918
+* fix(deps): update dependency com.google.firebase:firebase-bom to v34.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5915
+* fix(deps): update haze to v1.7.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5712
+* chore(deps): update peter-evans/create-pull-request action to v8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5878
+* fix(deps): update dependency com.posthog:posthog-android to v3.27.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5871
+* chore(deps): update plugin sonarqube to v7.2.1.6560 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5905
+* fix(deps): update metro to v0.9.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5920
+* fix(deps): update activity to v1.12.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5924
+* Update plugin sonarqube to v7.2.2.6593 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5927
+* fix(deps): update media3 to v1.9.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5931
+* fix(deps): update metro to v0.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5940
+* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5939
+* fix(deps): update dependency com.google.zxing:core to v3.5.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5935
+* Upgrade robolectric to version 4.16 by @bmarty in https://github.com/element-hq/element-x-android/pull/5923
+* fix(deps): update dependency androidx.webkit:webkit to v1.15.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5925
+* chore(deps): update github artifact actions (major) by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5932
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5883
+* fix(deps): update dependency io.github.sergio-sastre.composablepreviewscanner:android to v0.8.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5916
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5943
+* fix(deps): update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5917
+* fix(deps): update dependency com.posthog:posthog-android to v3.28.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5941
+* fix(deps): update wysiwyg to v2.41.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5921
+* fix(deps): update roborazzi to v1.53.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5962
+* fix(deps): update roborazzi to v1.54.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5970
+* fix(deps): update dependency org.unifiedpush.android:connector to v3.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5972
+* fix(deps): update metro to v0.9.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5967
+* Upgrade compose to 2025.12.01 by @bmarty in https://github.com/element-hq/element-x-android/pull/5969
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5977
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.1.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5986
+* fix(deps): update roborazzi to v1.56.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5987
+* fix(deps): update dependency com.posthog:posthog-android to v3.28.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5988
+* fix(deps): update metro to v0.9.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5991
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.1.12 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5999
+### Others
+* Enable Sentry in the SDK and allow bridging spans by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5808
+* Add alert to encrypted rooms with visible history (Android). by @kaylendog in https://github.com/element-hq/element-x-android/pull/5709
+* Add accessibility to the "sending" picto. by @bmarty in https://github.com/element-hq/element-x-android/pull/5869
+* Add SDK database vacuuming operations by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5858
+* Sync compound tokens https://github.com/element-hq/compound-design-tokens/releases/tag/v6.4.2 by @bmarty in https://github.com/element-hq/element-x-android/pull/5897
+* RoomSummary: move the icon related to the last message state on start of the message. by @bmarty in https://github.com/element-hq/element-x-android/pull/5888
+* Qr code scanner cleanup by @bmarty in https://github.com/element-hq/element-x-android/pull/5891
+* Design : update user rows by @ganfra in https://github.com/element-hq/element-x-android/pull/5900
+* misc : rework power levels apis by @ganfra in https://github.com/element-hq/element-x-android/pull/5879
+* Fix preview name by @bmarty in https://github.com/element-hq/element-x-android/pull/5919
+* Allow uploading extra data to Sentry when analytics is enabled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5910
+* Show history visibility banner strictly for `shared` rooms instead of `invited`. by @kaylendog in https://github.com/element-hq/element-x-android/pull/5936
+* Simplify the copy of the history visibility settings by @bmarty in https://github.com/element-hq/element-x-android/pull/5942
+* Use only font from compound by @bmarty in https://github.com/element-hq/element-x-android/pull/5945
+* Cleanup FFI object fixtures. by @bmarty in https://github.com/element-hq/element-x-android/pull/5957
+* Add variable playback speed feature for voice messages by @Medformatik in https://github.com/element-hq/element-x-android/pull/5504
+* Ensure that avatars always have a content description. by @bmarty in https://github.com/element-hq/element-x-android/pull/5968
+* Ensure space feature is enabled by @ganfra in https://github.com/element-hq/element-x-android/pull/5960
+* Adjust metrics to the new specifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5937
+* Use `TextFieldState` for room list search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5975
+* fix(deps): update roborazzi to v1.55.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5976
+* Iterate on verification screen by @bmarty in https://github.com/element-hq/element-x-android/pull/5981
+* Add preview with a11y details. by @bmarty in https://github.com/element-hq/element-x-android/pull/5984
+* Change the title for `AnalyticsTransactions.coldStart` and `.catchUp` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5998
+* [a11y] voice message improvements by @bmarty in https://github.com/element-hq/element-x-android/pull/5980
+
+## New Contributors
+* @Medformatik made their first contribution in https://github.com/element-hq/element-x-android/pull/5504
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.12.0...v26.01.0
+
Changes in Element X v25.12.0
=============================
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
index 1f6609ecc3..38dcd67c9e 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
@@ -25,4 +25,9 @@ object RageshakeConfig {
* The maximum size of the upload request. Default value is just below CloudFlare's max request size.
*/
const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L
+
+ /**
+ * The maximum size of a single log file.
+ */
+ const val MAX_LOG_CONTENT_SIZE = 100 * 1024 * 1024L
}
diff --git a/fastlane/metadata/android/en-US/changelogs/202601010.txt b/fastlane/metadata/android/en-US/changelogs/202601010.txt
new file mode 100644
index 0000000000..605371b852
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202601010.txt
@@ -0,0 +1,2 @@
+Main changes in this version: iterated on spaces, improved the room list stability and performance, fix an issue with the cached well-known config.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
index 50b9559e94..d8426b5321 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
@@ -46,6 +46,7 @@ import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.libraries.core.extensions.orEmpty
+import io.element.android.libraries.core.extensions.toSafeLength
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -227,7 +228,7 @@ private fun NameAndTimestampRow(
// Name
Text(
style = ElementTheme.typography.fontBodyLgMedium,
- text = name ?: stringResource(id = CommonStrings.common_no_room_name),
+ text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
@@ -380,7 +381,7 @@ private fun InviteNameAndIndicatorRow(
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
- text = name ?: stringResource(id = CommonStrings.common_no_room_name),
+ text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt
index 92b3447615..240a3143af 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt
@@ -54,6 +54,7 @@ fun EnterNumberView(
subTitle = stringResource(R.string.screen_link_new_device_enter_number_subtitle),
iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
modifier = modifier,
+ isScrollable = true,
buttons = {
Button(
text = stringResource(CommonStrings.action_continue),
diff --git a/features/login/impl/src/main/AndroidManifest.xml b/features/login/impl/src/main/AndroidManifest.xml
index 453cf05132..f2d84131a7 100644
--- a/features/login/impl/src/main/AndroidManifest.xml
+++ b/features/login/impl/src/main/AndroidManifest.xml
@@ -15,4 +15,6 @@
+
+
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt
index 4523e6f45e..12b9106b71 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt
@@ -14,6 +14,8 @@ import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.features.login.impl.changeserver.ChangeServerState
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicPresenter
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
import io.element.android.libraries.architecture.Presenter
@ContributesTo(AppScope::class)
@@ -21,4 +23,7 @@ import io.element.android.libraries.architecture.Presenter
interface LoginModule {
@Binds
fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter
+
+ @Binds
+ fun bindLoginWithClassicPresenter(presenter: LoginWithClassicPresenter): Presenter
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt
index 4d83c45a44..741f65234e 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt
@@ -26,6 +26,7 @@ import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginHelper
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@@ -44,6 +45,7 @@ class OnBoardingPresenter(
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
private val sessionStore: SessionStore,
private val accountProviderDataSource: AccountProviderDataSource,
+ private val loginWithClassicPresenter: Presenter,
) : Presenter {
@AssistedFactory
interface Factory {
@@ -99,6 +101,8 @@ class OnBoardingPresenter(
val loginMode by loginHelper.collectLoginMode()
+ val loginWithClassicState = loginWithClassicPresenter.present()
+
fun handleEvent(event: OnBoardingEvents) {
when (event) {
is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch {
@@ -132,6 +136,7 @@ class OnBoardingPresenter(
loginMode = loginMode,
version = buildMeta.versionName,
onBoardingLogoResId = onBoardingLogoResId,
+ loginWithClassicState = loginWithClassicState,
eventSink = ::handleEvent,
)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt
index db6c3573f9..703120b260 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt
@@ -10,6 +10,7 @@ package io.element.android.features.login.impl.screens.onboarding
import androidx.annotation.DrawableRes
import io.element.android.features.login.impl.login.LoginMode
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
import io.element.android.libraries.architecture.AsyncData
data class OnBoardingState(
@@ -24,6 +25,7 @@ data class OnBoardingState(
@DrawableRes
val onBoardingLogoResId: Int?,
val loginMode: AsyncData,
+ val loginWithClassicState: LoginWithClassicState,
val eventSink: (OnBoardingEvents) -> Unit,
) {
val submitEnabled: Boolean
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt
index d7db27ca0b..76f8eb3513 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt
@@ -11,6 +11,8 @@ package io.element.android.features.login.impl.screens.onboarding
import androidx.annotation.DrawableRes
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.login.LoginMode
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
+import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.R
@@ -44,6 +46,7 @@ fun anOnBoardingState(
@DrawableRes
customLogoResId: Int? = null,
loginMode: AsyncData = AsyncData.Uninitialized,
+ loginWithClassicState: LoginWithClassicState = aLoginWithClassicState(),
eventSink: (OnBoardingEvents) -> Unit = {},
) = OnBoardingState(
isAddingAccount = isAddingAccount,
@@ -56,5 +59,6 @@ fun anOnBoardingState(
version = version,
loginMode = loginMode,
onBoardingLogoResId = customLogoResId,
+ loginWithClassicState = loginWithClassicState,
eventSink = eventSink,
)
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 977c6de71c..d590f1fec8 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
@@ -31,10 +31,15 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginModeView
+import io.element.android.features.login.impl.screens.onboarding.classic.ConfirmingLoginWithElementClassic
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicEvent
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
@@ -42,6 +47,8 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.async.AsyncActionView
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
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
@@ -109,6 +116,43 @@ fun OnBoardingView(
buttons = buttons,
)
}
+
+ LoginWithElementClassicView(
+ state = state.loginWithClassicState,
+ )
+}
+
+@Composable
+private fun LoginWithElementClassicView(
+ state: LoginWithClassicState,
+) {
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
+ state.eventSink(LoginWithClassicEvent.RefreshData)
+ }
+ AsyncActionView(
+ async = state.loginWithClassicAction,
+ confirmationDialog = { confirming ->
+ when (confirming) {
+ is ConfirmingLoginWithElementClassic -> {
+ // TODO i18n
+ ConfirmationDialog(
+ title = "Sign in with Element Classic",
+ content = "You are signing in as ${confirming.userId} on Element Classic." +
+ " Your existing session on Element Classic will not be signed out. Do you want to continue?",
+ submitText = stringResource(CommonStrings.action_continue),
+ onSubmitClick = { state.eventSink(LoginWithClassicEvent.DoLoginWithClassic) },
+ onDismiss = { state.eventSink(LoginWithClassicEvent.CloseDialog) },
+ )
+ }
+ }
+ },
+ onErrorDismiss = {
+ state.eventSink(LoginWithClassicEvent.CloseDialog)
+ },
+ onSuccess = {
+ // noop, the view will be closed
+ }
+ )
}
@Composable
@@ -239,6 +283,18 @@ private fun OnBoardingButtons(
} else {
CommonStrings.action_continue
}
+ if (state.loginWithClassicState.canLoginWithClassic) {
+ Button(
+ text = "Sign in with Element Classic",
+ leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
+ onClick = {
+ state.loginWithClassicState.eventSink(
+ LoginWithClassicEvent.StartLoginWithClassic
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
if (state.canLoginWithQrCode) {
Button(
text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code),
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt
new file mode 100644
index 0000000000..5fae0afdd5
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.impl.screens.onboarding.classic
+
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.UserId
+
+class ConfirmingLoginWithElementClassic(
+ val userId: UserId,
+) : AsyncAction.Confirming
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt
new file mode 100644
index 0000000000..c983ea04ba
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt
@@ -0,0 +1,249 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.impl.screens.onboarding.classic
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Context.BIND_AUTO_CREATE
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Bundle
+import android.os.Handler
+import android.os.IBinder
+import android.os.Message
+import android.os.Messenger
+import android.os.RemoteException
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.core.meta.BuildType
+import io.element.android.libraries.di.annotations.AppCoroutineScope
+import io.element.android.libraries.di.annotations.ApplicationContext
+import io.element.android.libraries.matrix.api.core.UserId
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+interface ElementClassicConnection {
+ fun start()
+ fun stop()
+ fun requestData()
+ val stateFlow: StateFlow
+}
+
+sealed interface ElementClassicConnectionState {
+ object Idle : ElementClassicConnectionState
+ object ElementClassicNotFound : ElementClassicConnectionState
+ object ElementClassicReadyNoSession : ElementClassicConnectionState
+ data class ElementClassicReady(val userId: UserId) : ElementClassicConnectionState
+ data class Error(val error: String) : ElementClassicConnectionState
+}
+
+private val loggerTag = LoggerTag("ECConnection")
+
+@ContributesBinding(AppScope::class)
+class DefaultElementClassicConnection(
+ @ApplicationContext
+ private val context: Context,
+ @AppCoroutineScope
+ private val coroutineScope: CoroutineScope,
+ private val buildMeta: BuildMeta,
+) : ElementClassicConnection {
+ // Messenger for communicating with the service.
+ private var messenger: Messenger? = null
+
+ // Target we publish for external service to send messages to IncomingHandler.
+ private val incomingMessenger: Messenger = Messenger(IncomingHandler())
+
+ // Flag indicating whether we have called bind on the service.
+ private var bound: Boolean = false
+
+ /**
+ * Class for interacting with the main interface of the service.
+ */
+ private val serviceConnection = object : ServiceConnection {
+ override fun onServiceConnected(className: ComponentName, service: IBinder) {
+ Timber.tag(loggerTag.value).d("onServiceConnected")
+ // This is called when the connection with the service has been
+ // established, giving us the object we can use to
+ // interact with the service. We are communicating with the
+ // service using a Messenger, so here we get a client-side
+ // representation of that from the raw IBinder object.
+ messenger = Messenger(service)
+ bound = true
+ // Request the data as soon as possible
+ requestData()
+ }
+
+ override fun onServiceDisconnected(className: ComponentName) {
+ Timber.tag(loggerTag.value).d("onServiceDisconnected")
+ // This is called when the connection with the service has been
+ // unexpectedly disconnected—that is, its process crashed.
+ messenger = null
+ bound = false
+ }
+ }
+
+ override fun start() {
+ Timber.tag(loggerTag.value).w("start()")
+ coroutineScope.launch {
+ // Establish a connection with the service. We use an explicit
+ // class name because there is no reason to be able to let other
+ // applications replace our component.
+ try {
+ val intentService = Intent()
+ intentService.setComponent(getElementClassicComponent(buildMeta))
+ if (context.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) {
+ Timber.tag(loggerTag.value).d("Binding returned true")
+ } else {
+ // This happen when the app is not installed
+ Timber.tag(loggerTag.value).d("Binding returned false")
+ mutableStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound)
+ }
+ } catch (e: SecurityException) {
+ Timber.tag(loggerTag.value).e(e, "Can't bind to Service")
+ mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty()))
+ }
+ }
+ }
+
+ override fun stop() {
+ Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)")
+ if (bound) {
+ // Detach our existing connection.
+ context.unbindService(serviceConnection)
+ bound = false
+ }
+ coroutineScope.launch {
+ mutableStateFlow.emit(ElementClassicConnectionState.Idle)
+ }
+ }
+
+ override fun requestData() {
+ Timber.tag(loggerTag.value).w("requestData()")
+ coroutineScope.launch {
+ val finalMessenger = messenger
+ if (finalMessenger == null) {
+ Timber.tag(loggerTag.value).w("The messenger is null, can't request data")
+ mutableStateFlow.emit(ElementClassicConnectionState.Error("The messenger is null, can't request data"))
+ } else {
+ try {
+ // Get the data
+ val msg = Message.obtain(null, MSG_GET_DATA)
+ msg.replyTo = incomingMessenger
+ finalMessenger.send(msg)
+ } catch (e: RemoteException) {
+ // In this case the service has crashed before we could even
+ // do anything with it; we can count on soon being
+ // disconnected (and then reconnected if it can be restarted)
+ // so there is no need to do anything here.
+ Timber.tag(loggerTag.value).e(e, "RemoteException")
+ mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty()))
+ }
+ }
+ }
+ }
+
+ private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle)
+ override val stateFlow = mutableStateFlow.asStateFlow()
+
+ /**
+ * Handler of incoming messages from service.
+ */
+ @Suppress("DEPRECATION")
+ inner class IncomingHandler : Handler() {
+ override fun handleMessage(msg: Message) {
+ Timber.tag(loggerTag.value).d("IncomingHandler handling message ${msg.what}")
+ when (msg.what) {
+ MSG_GET_DATA -> {
+ // The data must be extracted from the bundle before we launch the coroutine, else the bundle will be emptied
+ val state = msg.data.toElementClassicConnectionState()
+ emitElementClassicState(state)
+ }
+ else -> {
+ super.handleMessage(msg)
+ }
+ }
+ }
+ }
+
+ private fun emitElementClassicState(state: ElementClassicConnectionState) = coroutineScope.launch {
+ when (state) {
+ is ElementClassicConnectionState.Error -> {
+ Timber.tag(loggerTag.value).w("Received error from Element Classic: %s", state.error)
+ mutableStateFlow.emit(state)
+ }
+ is ElementClassicConnectionState.ElementClassicReady -> {
+ Timber.tag(loggerTag.value).d("Received userId from Element Classic: %s", state.userId)
+ mutableStateFlow.emit(state)
+ }
+ ElementClassicConnectionState.ElementClassicReadyNoSession -> {
+ Timber.tag(loggerTag.value).d("Received no session from Element Classic")
+ mutableStateFlow.emit(state)
+ }
+ else -> {
+ // Should not happen
+ Timber.tag(loggerTag.value).w("Received unexpected state from Element Classic: %s", state)
+ mutableStateFlow.emit(ElementClassicConnectionState.Idle)
+ }
+ }
+ }
+
+ private fun getElementClassicComponent(buildMeta: BuildMeta) = ComponentName(
+ buildString {
+ append(ELEMENT_CLASSIC_APP_ID)
+ append(
+ when (buildMeta.buildType) {
+ BuildType.DEBUG -> ELEMENT_CLASSIC_APP_ID_DEBUG_SUFFIX
+ BuildType.NIGHTLY -> ELEMENT_CLASSIC_APP_ID_NIGHTLY_SUFFIX
+ BuildType.RELEASE -> ELEMENT_CLASSIC_APP_ID_RELEASE_SUFFIX
+ }
+ )
+ },
+ ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME,
+ )
+
+ private fun Bundle?.toElementClassicConnectionState(): ElementClassicConnectionState {
+ return if (this == null) {
+ ElementClassicConnectionState.Error("No data received from Element Classic")
+ } else {
+ val error = getString(KEY_ERROR_STR)
+ if (error != null) {
+ ElementClassicConnectionState.Error(error)
+ } else {
+ val userId = getString(KEY_USER_ID_STR)?.let(::UserId)
+ if (userId != null) {
+ ElementClassicConnectionState.ElementClassicReady(userId)
+ } else {
+ ElementClassicConnectionState.ElementClassicReadyNoSession
+ }
+ }
+ }
+ }
+
+ // Everything in this companion object must match what is defined in Element Classic
+ private companion object {
+ // Command to the service to get the data.
+ const val MSG_GET_DATA = 1
+
+ const val ELEMENT_CLASSIC_APP_ID = "im.vector.app"
+ const val ELEMENT_CLASSIC_APP_ID_DEBUG_SUFFIX = ".debug"
+ const val ELEMENT_CLASSIC_APP_ID_NIGHTLY_SUFFIX = ".nightly"
+ const val ELEMENT_CLASSIC_APP_ID_RELEASE_SUFFIX = ""
+
+ const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService"
+
+ // Keys for the bundle returned from the service
+ const val KEY_ERROR_STR = "error"
+ const val KEY_USER_ID_STR = "userId"
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt
new file mode 100644
index 0000000000..75a9496a02
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.impl.screens.onboarding.classic
+
+sealed interface LoginWithClassicEvent {
+ data object RefreshData : LoginWithClassicEvent
+ data object StartLoginWithClassic : LoginWithClassicEvent
+ data object DoLoginWithClassic : LoginWithClassicEvent
+ data object CloseDialog : LoginWithClassicEvent
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt
new file mode 100644
index 0000000000..ef352794cb
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.impl.screens.onboarding.classic
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.sessionstorage.api.SessionStore
+import io.element.android.libraries.sessionstorage.api.toUserListFlow
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Inject
+class LoginWithClassicPresenter(
+ private val elementClassicConnection: ElementClassicConnection,
+ private val sessionStore: SessionStore,
+ private val featureFlagService: FeatureFlagService,
+) : Presenter {
+ @Composable
+ override fun present(): LoginWithClassicState {
+ val coroutineScope = rememberCoroutineScope()
+
+ val isSignInWithClassicEnabled by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.SignInWithClassic)
+ }.collectAsState(initial = false)
+
+ if (isSignInWithClassicEnabled) {
+ DisposableEffect(Unit) {
+ elementClassicConnection.start()
+ onDispose {
+ elementClassicConnection.stop()
+ }
+ }
+ }
+
+ val state by elementClassicConnection.stateFlow.collectAsState()
+ val loginWithClassicAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
+
+ val existingSession by remember {
+ sessionStore.sessionsFlow().toUserListFlow()
+ }.collectAsState(emptyList())
+
+ val canLoginWithClassic by remember {
+ derivedStateOf {
+ when (val finalState = state) {
+ is ElementClassicConnectionState.ElementClassicReady -> {
+ // Ensure there is no existing session with the same Id.
+ finalState.userId.value !in existingSession && isSignInWithClassicEnabled
+ }
+ else -> false
+ }
+ }
+ }
+
+ fun handleEvent(event: LoginWithClassicEvent) {
+ when (event) {
+ LoginWithClassicEvent.RefreshData -> {
+ elementClassicConnection.requestData()
+ }
+ LoginWithClassicEvent.StartLoginWithClassic -> {
+ val currentState = elementClassicConnection.stateFlow.value
+ if (currentState is ElementClassicConnectionState.ElementClassicReady) {
+ loginWithClassicAction.value = ConfirmingLoginWithElementClassic(
+ userId = currentState.userId,
+ )
+ } else {
+ loginWithClassicAction.value = AsyncAction.Failure(IllegalStateException("Element Classic is not ready"))
+ }
+ }
+ LoginWithClassicEvent.DoLoginWithClassic -> coroutineScope.launch {
+ // TODO Implement real login logic here
+ loginWithClassicAction.value = AsyncAction.Loading
+ delay(1000)
+ loginWithClassicAction.value = AsyncAction.Success(Unit)
+ }
+ LoginWithClassicEvent.CloseDialog -> {
+ loginWithClassicAction.value = AsyncAction.Uninitialized
+ }
+ }
+ }
+
+ return LoginWithClassicState(
+ canLoginWithClassic = canLoginWithClassic,
+ loginWithClassicAction = loginWithClassicAction.value,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt
new file mode 100644
index 0000000000..d2706fc24a
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.impl.screens.onboarding.classic
+
+import io.element.android.libraries.architecture.AsyncAction
+
+data class LoginWithClassicState(
+ val canLoginWithClassic: Boolean,
+ val loginWithClassicAction: AsyncAction,
+ val eventSink: (LoginWithClassicEvent) -> Unit,
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt
new file mode 100644
index 0000000000..73f68e5d61
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.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.features.login.impl.screens.onboarding.classic
+
+import io.element.android.libraries.architecture.AsyncAction
+
+fun aLoginWithClassicState(
+ canLoginWithClassic: Boolean = false,
+ loginWithClassicAction: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (LoginWithClassicEvent) -> Unit = {},
+) = LoginWithClassicState(
+ canLoginWithClassic = canLoginWithClassic,
+ loginWithClassicAction = loginWithClassicAction,
+ eventSink = eventSink,
+)
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt
index 1d434997ca..1e971ef265 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt
@@ -16,6 +16,7 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginHelper
+import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetriever
@@ -88,7 +89,10 @@ class OnBoardingPresenterTest {
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
assertThat(initialState.canReportBug).isFalse()
assertThat(initialState.isAddingAccount).isFalse()
- assertThat(awaitItem().canLoginWithQrCode).isTrue()
+ assertThat(initialState.loginWithClassicState.canLoginWithClassic).isFalse()
+ val finalState = awaitItem()
+ assertThat(finalState.canLoginWithQrCode).isTrue()
+ assertThat(finalState.loginWithClassicState.canLoginWithClassic).isFalse()
}
}
@@ -283,6 +287,7 @@ private fun createPresenter(
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
sessionStore = sessionStore,
accountProviderDataSource = accountProviderDataSource,
+ loginWithClassicPresenter = { aLoginWithClassicState() },
)
fun createLoginHelper(
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt
new file mode 100644
index 0000000000..2c41d2ed0f
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.login.impl.screens.onboarding.classic
+
+import io.element.android.tests.testutils.lambda.lambdaError
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class FakeElementClassicConnection(
+ private val startResult: () -> Unit = { lambdaError() },
+ private val stopResult: () -> Unit = { lambdaError() },
+ private val requestDataResult: () -> Unit = { lambdaError() },
+ initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle
+) : ElementClassicConnection {
+ override fun start() = startResult()
+ override fun stop() = stopResult()
+ override fun requestData() = requestDataResult()
+ private val mutableStateFlow = MutableStateFlow(initialState)
+ override val stateFlow: StateFlow = mutableStateFlow.asStateFlow()
+ suspend fun emitState(state: ElementClassicConnectionState) {
+ mutableStateFlow.emit(state)
+ }
+}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt
new file mode 100644
index 0000000000..8a8e4985c9
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt
@@ -0,0 +1,213 @@
+/*
+ * 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(ExperimentalCoroutinesApi::class)
+
+package io.element.android.features.login.impl.screens.onboarding.classic
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.sessionstorage.api.SessionStore
+import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
+import io.element.android.libraries.sessionstorage.test.aSessionData
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class LoginWithClassicPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state - feature disabled - start is not invoked`() = runTest {
+ val presenter = createPresenter(
+ elementClassicConnection = FakeElementClassicConnection(
+ startResult = {
+ error("start should not be invoked when feature is disabled")
+ },
+ )
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.canLoginWithClassic).isFalse()
+ assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - feature enabled - start is invoked`() = runTest {
+ val startResult = lambdaRecorder {}
+ val presenter = createPresenter(
+ elementClassicConnection = FakeElementClassicConnection(
+ startResult = startResult,
+ ),
+ isFeatureEnabled = true,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.canLoginWithClassic).isFalse()
+ assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
+ val finalState = awaitItem()
+ assertThat(finalState.canLoginWithClassic).isFalse()
+ }
+ startResult.assertions().isCalledOnce()
+ }
+
+ @Test
+ fun `present - emit request data invokes the expected method`() = runTest {
+ val requestDataResult = lambdaRecorder {}
+ val presenter = createPresenter(
+ elementClassicConnection = FakeElementClassicConnection(
+ startResult = {},
+ requestDataResult = requestDataResult,
+ ),
+ isFeatureEnabled = true,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.canLoginWithClassic).isFalse()
+ assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
+ val nextState = awaitItem()
+ assertThat(nextState.canLoginWithClassic).isFalse()
+ nextState.eventSink(LoginWithClassicEvent.RefreshData)
+ }
+ requestDataResult.assertions().isCalledOnce()
+ }
+
+ @Test
+ fun `present - start login with wrong state emits an error`() = runTest {
+ val presenter = createPresenter(
+ elementClassicConnection = FakeElementClassicConnection(
+ startResult = {},
+ ),
+ isFeatureEnabled = true,
+ )
+ presenter.test {
+ skipItems(1)
+ val state = awaitItem()
+ state.eventSink(LoginWithClassicEvent.StartLoginWithClassic)
+ val errorState = awaitItem()
+ assertThat(errorState.loginWithClassicAction.isFailure()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - start login with correct state - user cancel`() = runTest {
+ val elementClassicConnection = FakeElementClassicConnection(
+ startResult = {},
+ )
+ val presenter = createPresenter(
+ elementClassicConnection = elementClassicConnection,
+ isFeatureEnabled = true,
+ )
+ presenter.test {
+ skipItems(2)
+ elementClassicConnection.emitState(
+ ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID)
+ )
+ val readyState = awaitItem()
+ assertThat(readyState.canLoginWithClassic).isTrue()
+ readyState.eventSink(LoginWithClassicEvent.StartLoginWithClassic)
+ val confirmingState = awaitItem()
+ assertThat(confirmingState.loginWithClassicAction.isConfirming()).isTrue()
+ assertThat((confirmingState.loginWithClassicAction as ConfirmingLoginWithElementClassic).userId).isEqualTo(A_USER_ID)
+ confirmingState.eventSink(LoginWithClassicEvent.CloseDialog)
+ val finalState = awaitItem()
+ assertThat(finalState.loginWithClassicAction.isUninitialized()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - start login with correct state - user confirms`() = runTest {
+ val elementClassicConnection = FakeElementClassicConnection(
+ startResult = {},
+ )
+ val presenter = createPresenter(
+ elementClassicConnection = elementClassicConnection,
+ isFeatureEnabled = true,
+ )
+ presenter.test {
+ skipItems(2)
+ elementClassicConnection.emitState(
+ ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID)
+ )
+ val readyState = awaitItem()
+ assertThat(readyState.canLoginWithClassic).isTrue()
+ readyState.eventSink(LoginWithClassicEvent.StartLoginWithClassic)
+ val confirmingState = awaitItem()
+ assertThat(confirmingState.loginWithClassicAction.isConfirming()).isTrue()
+ assertThat((confirmingState.loginWithClassicAction as ConfirmingLoginWithElementClassic).userId).isEqualTo(A_USER_ID)
+ confirmingState.eventSink(LoginWithClassicEvent.DoLoginWithClassic)
+ val loadingState = awaitItem()
+ assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue()
+ val finalState = awaitItem()
+ assertThat(finalState.loginWithClassicAction.isSuccess()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - cannot sign in if a session with the same account already exists`() = runTest {
+ val elementClassicConnection = FakeElementClassicConnection(
+ startResult = {},
+ )
+ val presenter = createPresenter(
+ elementClassicConnection = elementClassicConnection,
+ isFeatureEnabled = true,
+ sessionStore = InMemorySessionStore(
+ initialList = listOf(
+ aSessionData(
+ sessionId = A_USER_ID.value,
+ )
+ )
+ ),
+ )
+ presenter.test {
+ skipItems(2)
+ elementClassicConnection.emitState(
+ ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID)
+ )
+ // No new item, because canLoginWithClassic is still false
+ }
+ }
+
+ @Test
+ fun `present - cannot sign in if the feature is disabled`() = runTest {
+ val elementClassicConnection = FakeElementClassicConnection()
+ val presenter = createPresenter(
+ elementClassicConnection = elementClassicConnection,
+ isFeatureEnabled = false,
+ )
+ presenter.test {
+ skipItems(1)
+ // Note: it should not happen IRL
+ elementClassicConnection.emitState(
+ ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID)
+ )
+ // No new item, because canLoginWithClassic is still false
+ }
+ }
+}
+
+private fun createPresenter(
+ elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
+ sessionStore: SessionStore = InMemorySessionStore(),
+ isFeatureEnabled: Boolean = false,
+ featureFlagService: FeatureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.SignInWithClassic.key to isFeatureEnabled)
+ ),
+) = LoginWithClassicPresenter(
+ elementClassicConnection = elementClassicConnection,
+ sessionStore = sessionStore,
+ featureFlagService = featureFlagService,
+)
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.kt
new file mode 100644
index 0000000000..a420ac8e8f
--- /dev/null
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.migration.impl.migrations
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesIntoSet
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.matrix.api.MatrixClientProvider
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.sessionstorage.api.SessionStore
+
+/**
+ * Ensure we clear the well-known cached config, since it could be invalid due to an SDK issue.
+ */
+@ContributesIntoSet(AppScope::class)
+@Inject
+class AppMigration09(
+ private val sessionStore: SessionStore,
+ private val matrixClientProvider: MatrixClientProvider,
+) : AppMigration {
+ override val order: Int = 9
+
+ override suspend fun migrate(isFreshInstall: Boolean) {
+ if (isFreshInstall) return
+
+ val sessions = sessionStore.getAllSessions()
+
+ for (session in sessions) {
+ val client = matrixClientProvider.getOrRestore(SessionId(session.userId)).getOrNull() ?: continue
+ client.resetWellKnownConfig()
+ }
+ }
+}
diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt
new file mode 100644
index 0000000000..380ea97464
--- /dev/null
+++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.migration.impl.migrations
+
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
+import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
+import io.element.android.libraries.sessionstorage.test.aSessionData
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class AppMigration09Test {
+ @Test
+ fun `migration on fresh install does nothing`() = runTest {
+ val sessionStore = InMemorySessionStore(initialList = listOf(aSessionData()))
+ val getClientLambda = lambdaRecorder> { Result.success(FakeMatrixClient()) }
+ val clientProvider = FakeMatrixClientProvider(getClient = getClientLambda)
+ val migration = AppMigration09(sessionStore, clientProvider)
+ migration.migrate(isFreshInstall = true)
+
+ getClientLambda.assertions().isNeverCalled()
+ }
+
+ @Test
+ fun `migration on upgrade should invoke the resetWellKnownConfig method`() = runTest {
+ val sessionStore = InMemorySessionStore(initialList = listOf(aSessionData()))
+ val resetWellKnownLambda = lambdaRecorder> { Result.success(Unit) }
+ val getClientLambda = lambdaRecorder> {
+ Result.success(FakeMatrixClient(resetWellKnownConfigLambda = resetWellKnownLambda))
+ }
+ val clientProvider = FakeMatrixClientProvider(getClient = getClientLambda)
+ val migration = AppMigration09(sessionStore, clientProvider)
+ migration.migrate(isFreshInstall = false)
+
+ getClientLambda.assertions().isCalledOnce()
+ resetWellKnownLambda.assertions().isCalledOnce()
+ }
+}
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
index f51d699350..f238f9d6ff 100755
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
@@ -158,6 +158,7 @@ class DefaultBugReporter(
}
if (withCrashLogs || withDevicesLogs) {
saveLogCat()
+ ?.takeIf { it.length() < RageshakeConfig.MAX_LOG_CONTENT_SIZE }
?.let { logCatFile ->
compressFile(logCatFile).also {
logCatFile.safeDelete()
@@ -191,6 +192,7 @@ class DefaultBugReporter(
.addFormDataPart("label", buildMeta.versionName)
.addFormDataPart("label", buildMeta.flavorDescription)
.addFormDataPart("branch_name", buildMeta.gitBranchName)
+
userId?.let {
matrixClientProvider.getOrNull(it)?.let { client ->
val curveKey = client.encryptionService.deviceCurve25519()
@@ -390,7 +392,11 @@ class DefaultBugReporter(
) {
val logDirectory = logDirectory()
logDirectory.listFiles()
- ?.filter { it.isFile && !it.name.endsWith(LOG_CAT_FILENAME) }
+ ?.filter {
+ it.isFile &&
+ !it.name.endsWith(LOG_CAT_FILENAME) &&
+ it.length() < RageshakeConfig.MAX_LOG_CONTENT_SIZE
+ }
}.orEmpty()
}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt
index 16a6ad1c3f..0f17a2f6f7 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt
@@ -8,6 +8,7 @@
package io.element.android.features.space.impl.root
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
sealed interface SpaceEvents {
@@ -19,4 +20,12 @@ sealed interface SpaceEvents {
data class ShowTopicViewer(val topic: String) : SpaceEvents
data object HideTopicViewer : SpaceEvents
+
+ // Manage mode events
+ data object EnterManageMode : SpaceEvents
+ data object ExitManageMode : SpaceEvents
+ data class ToggleRoomSelection(val roomId: RoomId) : SpaceEvents
+ data object ConfirmRoomRemoval : SpaceEvents
+ data object RemoveSelectedRooms : SpaceEvents
+ data object ClearRemoveAction : SpaceEvents
}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt
new file mode 100644
index 0000000000..fc9f5c9b0c
--- /dev/null
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.space.impl.root
+
+import io.element.android.features.space.impl.settings.SpaceSettingsPermissions
+import io.element.android.features.space.impl.settings.spaceSettingsPermissions
+import io.element.android.libraries.matrix.api.room.StateEventType
+import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
+
+/**
+ * Permissions needed for different actions in the Space screen.
+ * @param settingsPermissions Permissions related to space settings.
+ * @param canEditSpaceGraph Whether the user can edit the space graph (add/remove children).
+ */
+data class SpacePermissions(
+ val settingsPermissions: SpaceSettingsPermissions,
+ val canEditSpaceGraph: Boolean,
+) {
+ companion object {
+ val DEFAULT = SpacePermissions(
+ settingsPermissions = SpaceSettingsPermissions.DEFAULT,
+ canEditSpaceGraph = false,
+ )
+ }
+}
+
+fun RoomPermissions.spacePermissions(): SpacePermissions {
+ return SpacePermissions(
+ settingsPermissions = spaceSettingsPermissions(),
+ canEditSpaceGraph = canOwnUserSendState(StateEventType.SpaceChild) || canOwnUserSendState(StateEventType.SpaceParent),
+ )
+}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt
index 309747d2c9..a5bc2ec52e 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt
@@ -23,8 +23,6 @@ import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.toInviteData
-import io.element.android.features.space.impl.settings.SpaceSettingsPermissions
-import io.element.android.features.space.impl.settings.spaceSettingsPermissions
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
@@ -40,6 +38,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
+import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -48,9 +47,10 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
-import kotlin.jvm.optionals.getOrNull
@Inject
class SpacePresenter(
@@ -62,6 +62,7 @@ class SpacePresenter(
private val acceptDeclineInvitePresenter: Presenter,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val featureFlagService: FeatureFlagService,
+ private val spaceService: SpaceService,
) : Presenter {
private var children by mutableStateOf>(persistentListOf())
@@ -88,8 +89,8 @@ class SpacePresenter(
}
}.collectAsState()
- val permissions by room.permissionsAsState(SpaceSettingsPermissions.DEFAULT) { perms ->
- perms.spaceSettingsPermissions()
+ val permissions by room.permissionsAsState(SpacePermissions.DEFAULT) { perms ->
+ perms.spacePermissions()
}
val isSpaceSettingsEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.SpaceSettings)
@@ -97,13 +98,37 @@ class SpacePresenter(
val roomInfo by room.roomInfoFlow.collectAsState()
val canAccessSpaceSettings by remember {
- derivedStateOf { isSpaceSettingsEnabled && permissions.hasAny(roomInfo.joinRule) }
+ derivedStateOf { isSpaceSettingsEnabled && permissions.settingsPermissions.hasAny(roomInfo.joinRule) }
+ }
+ val canEditSpaceGraph by remember {
+ derivedStateOf { isSpaceSettingsEnabled && permissions.canEditSpaceGraph }
}
- val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState()
val (joinActions, setJoinActions) = remember { mutableStateOf(emptyMap>()) }
var topicViewerState: TopicViewerState by remember { mutableStateOf(TopicViewerState.Hidden) }
+ // Manage mode state
+ var isManageMode by remember { mutableStateOf(false) }
+ var selectedRoomIds by remember { mutableStateOf>(emptySet()) }
+ var removeRoomsAction by remember { mutableStateOf>(AsyncAction.Uninitialized) }
+ var removedRoomIds by remember { mutableStateOf>(emptySet()) }
+
+ val filteredChildren by remember {
+ derivedStateOf {
+ children
+ .filterNot { it.roomId in removedRoomIds }
+ .let { list ->
+ if (isManageMode) {
+ // In manage mode, only show rooms (not spaces)
+ list.filter { !it.isSpace }
+ } else {
+ list
+ }
+ }
+ .toImmutableList()
+ }
+ }
+
LaunchedEffect(children) {
// Remove joined children from the join actions
val joinedChildren = children
@@ -138,11 +163,60 @@ class SpacePresenter(
}
SpaceEvents.HideTopicViewer -> topicViewerState = TopicViewerState.Hidden
is SpaceEvents.ShowTopicViewer -> topicViewerState = TopicViewerState.Shown(event.topic)
+
+ // Manage mode events
+ SpaceEvents.EnterManageMode -> {
+ isManageMode = true
+ selectedRoomIds = emptySet()
+ }
+ SpaceEvents.ExitManageMode -> {
+ isManageMode = false
+ selectedRoomIds = emptySet()
+ }
+ is SpaceEvents.ToggleRoomSelection -> {
+ selectedRoomIds = if (event.roomId in selectedRoomIds) {
+ selectedRoomIds - event.roomId
+ } else {
+ selectedRoomIds + event.roomId
+ }
+ }
+ SpaceEvents.RemoveSelectedRooms -> {
+ removeRoomsAction = AsyncAction.ConfirmingNoParams
+ }
+ SpaceEvents.ConfirmRoomRemoval -> {
+ localCoroutineScope.launch {
+ removeRoomsAction = AsyncAction.Loading
+ val spaceId = spaceRoomList.roomId
+ val roomsToRemove = selectedRoomIds.toSet()
+ val successfullyRemoved = mutableSetOf()
+ val results = roomsToRemove.map { roomId ->
+ async {
+ spaceService.removeChildFromSpace(spaceId, roomId)
+ .onSuccess { successfullyRemoved.add(roomId) }
+ }
+ }
+ results.awaitAll()
+ if (successfullyRemoved.isNotEmpty()) {
+ removedRoomIds = removedRoomIds + successfullyRemoved
+ }
+ val hasError = successfullyRemoved.size < roomsToRemove.size
+ if (hasError) {
+ removeRoomsAction = AsyncAction.Failure(Exception("Failed to remove some rooms"))
+ } else {
+ removeRoomsAction = AsyncAction.Success(Unit)
+ isManageMode = false
+ selectedRoomIds = emptySet()
+ }
+ }
+ }
+ SpaceEvents.ClearRemoveAction -> {
+ removeRoomsAction = AsyncAction.Uninitialized
+ }
}
}
return SpaceState(
- currentSpace = currentSpace.getOrNull(),
- children = children,
+ spaceInfo = roomInfo,
+ children = filteredChildren,
seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad,
@@ -150,6 +224,10 @@ class SpacePresenter(
acceptDeclineInviteState = acceptDeclineInviteState,
topicViewerState = topicViewerState,
canAccessSpaceSettings = canAccessSpaceSettings,
+ isManageMode = isManageMode,
+ selectedRoomIds = selectedRoomIds.toImmutableSet(),
+ canEditSpaceGraph = canEditSpaceGraph,
+ removeRoomsAction = removeRoomsAction,
eventSink = ::handleEvent,
)
}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt
index cceda62806..a669c294b5 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt
@@ -12,13 +12,14 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
data class SpaceState(
- val currentSpace: SpaceRoom?,
+ val spaceInfo: RoomInfo,
val children: ImmutableList,
val seenSpaceInvites: ImmutableSet,
val hideInvitesAvatar: Boolean,
@@ -27,12 +28,21 @@ data class SpaceState(
val acceptDeclineInviteState: AcceptDeclineInviteState,
val topicViewerState: TopicViewerState,
val canAccessSpaceSettings: Boolean,
+ val isManageMode: Boolean,
+ val selectedRoomIds: ImmutableSet,
+ val canEditSpaceGraph: Boolean,
+ val removeRoomsAction: AsyncAction,
val eventSink: (SpaceEvents) -> Unit
) {
fun isJoining(spaceId: RoomId): Boolean = joinActions[spaceId] == AsyncAction.Loading
- val hasAnyFailure: Boolean = joinActions.values.any {
+ fun isSelected(spaceId: RoomId): Boolean = selectedRoomIds.contains(spaceId)
+ val hasAnyJoinFailures: Boolean = joinActions.values.any {
it is AsyncAction.Failure
}
+
+ val showManageRoomsAction: Boolean = canEditSpaceGraph && children.any { spaceRoom -> !spaceRoom.isSpace }
+ val selectedCount: Int = selectedRoomIds.size
+ val isRemoveButtonEnabled: Boolean = selectedRoomIds.isNotEmpty()
}
@Immutable
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt
index 52894ad599..9641e17625 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt
@@ -15,6 +15,8 @@ import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInvit
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
+import io.element.android.libraries.matrix.api.room.RoomInfo
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
@@ -27,11 +29,11 @@ open class SpaceStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aSpaceState(),
- aSpaceState(parentSpace = aParentSpace(joinRule = JoinRule.Public)),
- aSpaceState(parentSpace = aParentSpace(joinRule = JoinRule.Restricted(persistentListOf()))),
+ aSpaceState(spaceInfo = aSpaceInfo(joinRule = JoinRule.Public)),
+ aSpaceState(spaceInfo = aSpaceInfo(joinRule = JoinRule.Restricted(persistentListOf()))),
aSpaceState(children = aListOfSpaceRooms()),
aSpaceState(
- parentSpace = aParentSpace(),
+ spaceInfo = aSpaceInfo(),
children = aListOfSpaceRooms(),
joiningRooms = setOf(RoomId("!spaceId0:example.com")),
hasMoreToLoad = false
@@ -39,12 +41,31 @@ open class SpaceStateProvider : PreviewParameterProvider {
aSpaceState(
topicViewerState = TopicViewerState.Shown(topic = "Space description goes here." + LoremIpsum(20).values.first()),
),
- // Add other states here
+ // Manage mode states
+ aSpaceState(
+ spaceInfo = aSpaceInfo(),
+ children = aListOfSpaceRooms(),
+ isManageMode = true,
+ selectedRoomIds = emptySet(),
+ ),
+ aSpaceState(
+ spaceInfo = aSpaceInfo(),
+ children = aListOfSpaceRooms(),
+ isManageMode = true,
+ selectedRoomIds = setOf(RoomId("!spaceId0:example.com"), RoomId("!spaceId1:example.com")),
+ ),
+ aSpaceState(
+ spaceInfo = aSpaceInfo(),
+ children = aListOfSpaceRooms(),
+ isManageMode = true,
+ selectedRoomIds = setOf(RoomId("!spaceId0:example.com")),
+ removeRoomsAction = AsyncAction.ConfirmingNoParams,
+ ),
)
}
fun aSpaceState(
- parentSpace: SpaceRoom? = aParentSpace(),
+ spaceInfo: RoomInfo = aSpaceInfo(),
children: List = emptyList(),
seenSpaceInvites: Set = emptySet(),
joiningRooms: Set = emptySet(),
@@ -54,9 +75,13 @@ fun aSpaceState(
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
topicViewerState: TopicViewerState = TopicViewerState.Hidden,
canAccessSpaceSettings: Boolean = true,
+ isManageMode: Boolean = false,
+ selectedRoomIds: Set = emptySet(),
+ canManageRooms: Boolean = true,
+ removeRoomsAction: AsyncAction = AsyncAction.Uninitialized,
eventSink: (SpaceEvents) -> Unit = { },
) = SpaceState(
- currentSpace = parentSpace,
+ spaceInfo = spaceInfo,
children = children.toImmutableList(),
seenSpaceInvites = seenSpaceInvites.toImmutableSet(),
hideInvitesAvatar = hideInvitesAvatar,
@@ -65,19 +90,52 @@ fun aSpaceState(
acceptDeclineInviteState = acceptDeclineInviteState,
topicViewerState = topicViewerState,
canAccessSpaceSettings = canAccessSpaceSettings,
+ isManageMode = isManageMode,
+ selectedRoomIds = selectedRoomIds.toImmutableSet(),
+ canEditSpaceGraph = canManageRooms,
+ removeRoomsAction = removeRoomsAction,
eventSink = eventSink,
)
-private fun aParentSpace(
+private fun aSpaceInfo(
joinRule: JoinRule? = null,
-): SpaceRoom {
- return aSpaceRoom(
- numJoinedMembers = 5,
- childrenCount = 10,
- worldReadable = true,
- joinRule = joinRule,
- roomId = RoomId("!spaceId0:example.com"),
+): RoomInfo {
+ return RoomInfo(
+ id = RoomId("!spaceId0:example.com"),
+ name = "A Space",
+ rawName = "A Space",
topic = "Space description goes here. " + LoremIpsum(20).values.first(),
+ avatarUrl = null,
+ isPublic = true,
+ isDirect = false,
+ isEncrypted = false,
+ joinRule = joinRule,
+ isSpace = true,
+ isFavorite = false,
+ canonicalAlias = null,
+ alternativeAliases = persistentListOf(),
+ currentUserMembership = CurrentUserMembership.JOINED,
+ inviter = null,
+ activeMembersCount = 5,
+ invitedMembersCount = 0,
+ joinedMembersCount = 5,
+ roomPowerLevels = null,
+ highlightCount = 0,
+ notificationCount = 0,
+ userDefinedNotificationMode = null,
+ hasRoomCall = false,
+ activeRoomCallParticipants = persistentListOf(),
+ isMarkedUnread = false,
+ numUnreadMessages = 0,
+ numUnreadNotifications = 0,
+ numUnreadMentions = 0,
+ heroes = persistentListOf(),
+ pinnedEventIds = persistentListOf(),
+ creators = persistentListOf(),
+ historyVisibility = RoomHistoryVisibility.Joined,
+ successorRoom = null,
+ roomVersion = "11",
+ privilegedCreatorRole = false,
)
}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt
index 769b608e8e..695b887496 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt
@@ -8,10 +8,17 @@
package io.element.android.features.space.impl.root
+import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -31,6 +38,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
@@ -40,9 +48,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.space.impl.R
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.SimpleModalBottomSheet
+import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
@@ -51,8 +62,10 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
@@ -61,15 +74,18 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
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.api.room.CurrentUserMembership
+import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
+import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility
import io.element.android.libraries.matrix.ui.components.JoinButton
import io.element.android.libraries.matrix.ui.components.SpaceHeaderView
import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView
import io.element.android.libraries.matrix.ui.model.getAvatarData
+import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
-import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@@ -85,18 +101,48 @@ fun SpaceView(
modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit,
) {
+ BackHandler {
+ if (state.isManageMode) {
+ state.eventSink(SpaceEvents.ExitManageMode)
+ } else {
+ onBackClick()
+ }
+ }
+
Scaffold(
modifier = modifier,
topBar = {
- SpaceViewTopBar(
- currentSpace = state.currentSpace,
- canAccessSpaceSettings = state.canAccessSpaceSettings,
- onBackClick = onBackClick,
- onLeaveSpaceClick = onLeaveSpaceClick,
- onShareSpace = onShareSpace,
- onSettingsClick = onSettingsClick,
- onViewMembersClick = onViewMembersClick,
- )
+ Box {
+ AnimatedVisibility(
+ visible = state.isManageMode,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ ManageModeTopBar(
+ selectedCount = state.selectedCount,
+ isRemoveButtonEnabled = state.isRemoveButtonEnabled,
+ onCancelClick = { state.eventSink(SpaceEvents.ExitManageMode) },
+ onRemoveClick = { state.eventSink(SpaceEvents.RemoveSelectedRooms) },
+ )
+ }
+ AnimatedVisibility(
+ visible = !state.isManageMode,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ SpaceViewTopBar(
+ spaceInfo = state.spaceInfo,
+ canAccessSpaceSettings = state.canAccessSpaceSettings,
+ showManageRoomsAction = state.showManageRoomsAction,
+ onBackClick = onBackClick,
+ onLeaveSpaceClick = onLeaveSpaceClick,
+ onSettingsClick = onSettingsClick,
+ onShareSpace = onShareSpace,
+ onViewMembersClick = onViewMembersClick,
+ onManageRoomsClick = { state.eventSink(SpaceEvents.EnterManageMode) },
+ )
+ }
+ }
},
content = { padding ->
Box(
@@ -104,15 +150,28 @@ fun SpaceView(
) {
SpaceViewContent(
state = state,
- onRoomClick = onRoomClick,
+ onRoomClick = { spaceRoom ->
+ if (state.isManageMode) {
+ state.eventSink(SpaceEvents.ToggleRoomSelection(spaceRoom.roomId))
+ } else {
+ onRoomClick(spaceRoom)
+ }
+ },
onTopicClick = { topic ->
state.eventSink(SpaceEvents.ShowTopicViewer(topic))
}
)
- JoinRoomFailureEffect(
- hasAnyFailure = state.hasAnyFailure,
+ JoinFailuresEffect(
+ hasAnyFailure = state.hasAnyJoinFailures,
eventSink = state.eventSink
)
+ RemoveRoomsActionView(
+ spaceDisplayName = state.spaceInfo.name ?: state.spaceInfo.id.value,
+ removeRoomsAction = state.removeRoomsAction,
+ selectedCount = state.selectedCount,
+ onConfirm = { state.eventSink(SpaceEvents.ConfirmRoomRemoval) },
+ onDismiss = { state.eventSink(SpaceEvents.ClearRemoveAction) },
+ )
acceptDeclineInviteView()
}
},
@@ -128,7 +187,7 @@ fun SpaceView(
}
@Composable
-private fun JoinRoomFailureEffect(
+private fun JoinFailuresEffect(
hasAnyFailure: Boolean,
eventSink: (SpaceEvents) -> Unit,
) {
@@ -176,22 +235,26 @@ private fun SpaceViewContent(
modifier: Modifier = Modifier,
) {
LazyColumn(modifier.fillMaxSize()) {
- val currentSpace = state.currentSpace
- if (currentSpace != null) {
- item {
- SpaceHeaderView(
- avatarData = currentSpace.getAvatarData(AvatarSize.SpaceHeader),
- name = currentSpace.displayName,
- topic = currentSpace.topic,
- topicMaxLines = 2,
- visibility = currentSpace.visibility,
- heroes = currentSpace.heroes.toImmutableList(),
- numberOfMembers = currentSpace.numJoinedMembers,
- onTopicClick = onTopicClick
- )
- }
- item {
- HorizontalDivider()
+ val spaceInfo = state.spaceInfo
+ item(key = "space_header") {
+ AnimatedVisibility(
+ !state.isManageMode,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically()
+ ) {
+ Column {
+ SpaceHeaderView(
+ avatarData = spaceInfo.getAvatarData(AvatarSize.SpaceHeader),
+ name = spaceInfo.name,
+ topic = spaceInfo.topic,
+ topicMaxLines = 2,
+ visibility = SpaceRoomVisibility.fromJoinRule(spaceInfo.joinRule),
+ heroes = spaceInfo.heroes,
+ numberOfMembers = spaceInfo.joinedMembersCount.toInt(),
+ onTopicClick = onTopicClick
+ )
+ HorizontalDivider()
+ }
}
}
itemsIndexed(
@@ -200,9 +263,11 @@ private fun SpaceViewContent(
) { index, spaceRoom ->
val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
val isCurrentlyJoining = state.isJoining(spaceRoom.roomId)
+ val isSelected = state.isSelected(spaceRoom.roomId)
+ val showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites && !state.isManageMode
SpaceRoomItemView(
spaceRoom = spaceRoom,
- showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites,
+ showUnreadIndicator = showUnreadIndicator,
hideAvatars = isInvitation && state.hideInvitesAvatar,
onClick = {
onRoomClick(spaceRoom)
@@ -210,17 +275,30 @@ private fun SpaceViewContent(
onLongClick = {
// TODO
},
- trailingAction = spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) {
- state.eventSink(SpaceEvents.Join(spaceRoom))
- },
- bottomAction = spaceRoom.inviteButtons(
- onAcceptClick = {
- state.eventSink(SpaceEvents.AcceptInvite(spaceRoom))
- },
- onDeclineClick = {
- state.eventSink(SpaceEvents.DeclineInvite(spaceRoom))
+ trailingAction = if (state.isManageMode) {
+ {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = null,
+ )
}
- )
+ } else {
+ spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) {
+ state.eventSink(SpaceEvents.Join(spaceRoom))
+ }
+ },
+ bottomAction = if (state.isManageMode) {
+ null
+ } else {
+ spaceRoom.inviteButtons(
+ onAcceptClick = {
+ state.eventSink(SpaceEvents.AcceptInvite(spaceRoom))
+ },
+ onDeclineClick = {
+ state.eventSink(SpaceEvents.DeclineInvite(spaceRoom))
+ }
+ )
+ }
)
if (index != state.children.lastIndex) {
HorizontalDivider()
@@ -257,13 +335,15 @@ private fun LoadingMoreIndicator(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SpaceViewTopBar(
- currentSpace: SpaceRoom?,
+ spaceInfo: RoomInfo,
canAccessSpaceSettings: Boolean,
+ showManageRoomsAction: Boolean,
onBackClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
onSettingsClick: () -> Unit,
onShareSpace: () -> Unit,
onViewMembersClick: () -> Unit,
+ onManageRoomsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
@@ -272,16 +352,14 @@ private fun SpaceViewTopBar(
BackButton(onClick = onBackClick)
},
title = {
- if (currentSpace != null) {
- val roundedCornerShape = RoundedCornerShape(8.dp)
- SpaceAvatarAndNameRow(
- name = currentSpace.displayName,
- avatarData = currentSpace.getAvatarData(AvatarSize.TimelineRoom),
- modifier = Modifier
- .clip(roundedCornerShape)
- .clickable(enabled = canAccessSpaceSettings, onClick = onSettingsClick)
- )
- }
+ val roundedCornerShape = RoundedCornerShape(8.dp)
+ SpaceAvatarAndNameRow(
+ name = spaceInfo.name,
+ avatarData = spaceInfo.getAvatarData(AvatarSize.TimelineRoom),
+ modifier = Modifier
+ .clip(roundedCornerShape)
+ .clickable(enabled = canAccessSpaceSettings, onClick = onSettingsClick)
+ )
},
actions = {
var showMenu by remember { mutableStateOf(false) }
@@ -297,8 +375,19 @@ private fun SpaceViewTopBar(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
+ if (showManageRoomsAction) {
+ SpaceMenuItem(
+ titleRes = CommonStrings.action_manage_rooms,
+ icon = CompoundIcons.Edit(),
+ onClick = {
+ showMenu = false
+ onManageRoomsClick()
+ }
+ )
+ HorizontalDivider()
+ }
SpaceMenuItem(
- titleRes = CommonStrings.screen_space_menu_action_members,
+ titleRes = R.string.screen_space_menu_action_members,
icon = CompoundIcons.User(),
onClick = {
showMenu = false
@@ -323,6 +412,7 @@ private fun SpaceViewTopBar(
}
)
}
+ HorizontalDivider()
SpaceMenuItem(
titleRes = CommonStrings.action_leave_space,
icon = CompoundIcons.Leave(),
@@ -337,6 +427,39 @@ private fun SpaceViewTopBar(
)
}
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ManageModeTopBar(
+ selectedCount: Int,
+ isRemoveButtonEnabled: Boolean,
+ onCancelClick: () -> Unit,
+ onRemoveClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ TopAppBar(
+ modifier = modifier,
+ navigationIcon = {
+ BackButton(
+ onClick = onCancelClick,
+ imageVector = CompoundIcons.Close()
+ )
+ },
+ title = {
+ Text(
+ text = pluralStringResource(CommonPlurals.common_selected_count, selectedCount, selectedCount),
+ style = ElementTheme.typography.fontBodyLgMedium,
+ )
+ },
+ actions = {
+ TextButton(
+ text = stringResource(CommonStrings.action_remove),
+ onClick = onRemoveClick,
+ enabled = isRemoveButtonEnabled,
+ )
+ },
+ )
+}
+
@Composable
private fun SpaceMenuItem(
@StringRes titleRes: Int,
@@ -425,6 +548,45 @@ private fun SpaceRoom.inviteButtons(
}
}
+@Composable
+private fun RemoveRoomsActionView(
+ spaceDisplayName: String,
+ removeRoomsAction: AsyncAction,
+ selectedCount: Int,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AsyncActionView(
+ async = removeRoomsAction,
+ confirmationDialog = {
+ ConfirmationDialog(
+ title = pluralStringResource(R.plurals.screen_space_remove_rooms_confirmation_title, selectedCount, selectedCount, spaceDisplayName),
+ content = stringResource(R.string.screen_space_remove_rooms_confirmation_content),
+ submitText = stringResource(CommonStrings.action_remove),
+ onSubmitClick = onConfirm,
+ onDismiss = onDismiss,
+ destructiveSubmit = true,
+ icon = {
+ Icon(
+ imageVector = CompoundIcons.Error(),
+ tint = ElementTheme.colors.textCriticalPrimary,
+ contentDescription = null
+ )
+ }
+ )
+ },
+ onRetry = onConfirm,
+ errorTitle = {
+ stringResource(CommonStrings.common_something_went_wrong)
+ },
+ errorMessage = {
+ stringResource(CommonStrings.error_network_or_server_issue)
+ },
+ onSuccess = { onDismiss() },
+ onErrorDismiss = onDismiss,
+ )
+}
+
@PreviewsDayNight
@Composable
internal fun SpaceViewPreview(
diff --git a/features/space/impl/src/main/res/values-bg/translations.xml b/features/space/impl/src/main/res/values-bg/translations.xml
index 0759934bbd..de0870f3f4 100644
--- a/features/space/impl/src/main/res/values-bg/translations.xml
+++ b/features/space/impl/src/main/res/values-bg/translations.xml
@@ -1,5 +1,6 @@
+ "Преглед на членовете"
"Напускане на пространството"
"Роли и разрешения"
"Защита и поверителност"
diff --git a/features/space/impl/src/main/res/values-cs/translations.xml b/features/space/impl/src/main/res/values-cs/translations.xml
index d4730fc62d..d98124c714 100644
--- a/features/space/impl/src/main/res/values-cs/translations.xml
+++ b/features/space/impl/src/main/res/values-cs/translations.xml
@@ -11,6 +11,7 @@
"Z následujících místností nebudete odstraněni, protože jste jediným administrátorem:"
"Opustit %1$s?"
"Jste jediným administrátorem pro %1$s"
+ "Zobrazit členy"
"Opustit prostor"
"Role a oprávnění"
"Zabezpečení a soukromí"
diff --git a/features/space/impl/src/main/res/values-da/translations.xml b/features/space/impl/src/main/res/values-da/translations.xml
index 6422b9635d..068712629d 100644
--- a/features/space/impl/src/main/res/values-da/translations.xml
+++ b/features/space/impl/src/main/res/values-da/translations.xml
@@ -10,6 +10,7 @@
"Du vil ikke blive fjernet fra følgende rum, fordi du er den eneste administrator:"
"Forlad %1$s?"
"Du er den eneste administrator for %1$s"
+ "Vis medlemmer"
"Forlad gruppe"
"Roller og tilladelser"
"Sikkerhed og privatliv"
diff --git a/features/space/impl/src/main/res/values-de/translations.xml b/features/space/impl/src/main/res/values-de/translations.xml
index a001756c6c..1d0238cf7f 100644
--- a/features/space/impl/src/main/res/values-de/translations.xml
+++ b/features/space/impl/src/main/res/values-de/translations.xml
@@ -10,6 +10,12 @@
"Du wirst aus den folgenden Chats nicht entfernt, weil du der einzige Admin bist:"
"%1$s verlassen?"
"Du bist der einzige Administrator für %1$s"
+ "Mitglieder anzeigen"
+ "Das Entfernen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\""
+
+ - "%1$d chat aus %2$s entfernen"
+ - "%1$d chats aus %2$s entfernen"
+
"Space verlassen"
"Rollen und Berechtigungen"
"Sicherheit & Datenschutz"
diff --git a/features/space/impl/src/main/res/values-et/translations.xml b/features/space/impl/src/main/res/values-et/translations.xml
index 43eaade351..fdee05be05 100644
--- a/features/space/impl/src/main/res/values-et/translations.xml
+++ b/features/space/impl/src/main/res/values-et/translations.xml
@@ -10,6 +10,7 @@
"Sind ei saa järgnevatest jututubadest eemaldada, kuna oled seal/neis ainus peakasutaja:"
"Kas lahkud %1$s kogukonnast?"
"Sa oled siin ainus peakasutaja: %1$s"
+ "Vaata liikmeid"
"Lahku kogukonnast"
"Rollid ja õigused"
"Turvalisus ja privaatsus"
diff --git a/features/space/impl/src/main/res/values-fa/translations.xml b/features/space/impl/src/main/res/values-fa/translations.xml
index bda53d0947..0f42a9f65f 100644
--- a/features/space/impl/src/main/res/values-fa/translations.xml
+++ b/features/space/impl/src/main/res/values-fa/translations.xml
@@ -5,6 +5,7 @@
"از اتاق(های) زیر برداشته نخواهید شد؛ چرا که تنها مدیر هستید:"
"ترک %1$s؟"
"تنها مدیر %1$s هستید"
+ "دیدن اعضا"
"ترک فضا"
"نقشها و اجازهها"
"امنیت و محرمانگی"
diff --git a/features/space/impl/src/main/res/values-fi/translations.xml b/features/space/impl/src/main/res/values-fi/translations.xml
index e43a4ae7f9..77771cf383 100644
--- a/features/space/impl/src/main/res/values-fi/translations.xml
+++ b/features/space/impl/src/main/res/values-fi/translations.xml
@@ -10,6 +10,7 @@
"Sinua ei poisteta seuraavista huoneista, koska olet ainoa ylläpitäjä:"
"Haluatko poistua tilasta %1$s?"
"Olet ainoa ylläpitäjä tilassa %1$s"
+ "Näytä jäsenet"
"Poistu tilasta"
"Roolit ja oikeudet"
"Turvallisuus ja yksityisyys"
diff --git a/features/space/impl/src/main/res/values-fr/translations.xml b/features/space/impl/src/main/res/values-fr/translations.xml
index befd4a7c92..89cc3e619f 100644
--- a/features/space/impl/src/main/res/values-fr/translations.xml
+++ b/features/space/impl/src/main/res/values-fr/translations.xml
@@ -10,6 +10,7 @@
"Vous ne quitterez pas le ou les salons suivants car vous y êtes le seul administrateur:"
"Quitter %1$s?"
"Vous êtes le seul administrateur de %1$s"
+ "Voir les membres"
"Quitter l’espace"
"Rôles & autorisations"
"Sécurité & confidentialité"
diff --git a/features/space/impl/src/main/res/values-hr/translations.xml b/features/space/impl/src/main/res/values-hr/translations.xml
index 9babbb3d69..5bd5400124 100644
--- a/features/space/impl/src/main/res/values-hr/translations.xml
+++ b/features/space/impl/src/main/res/values-hr/translations.xml
@@ -11,6 +11,7 @@
"Nećete biti uklonjeni iz sljedećih soba jer ste jedini administrator:"
"Želite li napustiti %1$s?"
"Vi ste jedini administrator za %1$s"
+ "Prikaži članove"
"Napusti prostor"
"Uloge i dopuštenja"
"Sigurnost i privatnost"
diff --git a/features/space/impl/src/main/res/values-hu/translations.xml b/features/space/impl/src/main/res/values-hu/translations.xml
index 3ddbe6c822..670e14cc3c 100644
--- a/features/space/impl/src/main/res/values-hu/translations.xml
+++ b/features/space/impl/src/main/res/values-hu/translations.xml
@@ -10,6 +10,7 @@
"Nem lesz eltávolítva a következő szobá(k)ból, mert ön az egyetlen adminisztrátor:"
"Kilép innen: %1$s?"
"Ön az egyetlen adminisztrátor itt: %1$s"
+ "Tagok megtekintése"
"Tér elhagyása"
"Szerepkörök és jogosultságok"
"Biztonság és adatvédelem"
diff --git a/features/space/impl/src/main/res/values-it/translations.xml b/features/space/impl/src/main/res/values-it/translations.xml
index e483f98513..f358c96d0d 100644
--- a/features/space/impl/src/main/res/values-it/translations.xml
+++ b/features/space/impl/src/main/res/values-it/translations.xml
@@ -10,6 +10,7 @@
"Non verrai rimosso dalle seguenti stanze perché sei l\'unico amministratore:"
"Uscire da %1$s?"
"Sei l\'unico amministratore di %1$s"
+ "Visualizza membri"
"Esci dallo spazio"
"Ruoli e autorizzazioni"
"Sicurezza e privacy"
diff --git a/features/space/impl/src/main/res/values-nb/translations.xml b/features/space/impl/src/main/res/values-nb/translations.xml
index 0e0709f80e..ebbe7be342 100644
--- a/features/space/impl/src/main/res/values-nb/translations.xml
+++ b/features/space/impl/src/main/res/values-nb/translations.xml
@@ -10,6 +10,7 @@
"Du vil ikke bli fjernet fra følgende rom fordi du er den eneste administratoren:"
"Forlat %1$s?"
"Du er den eneste administratoren for %1$s"
+ "Vis medlemmer"
"Forlat område"
"Roller og tillatelser"
"Sikkerhet og personvern"
diff --git a/features/space/impl/src/main/res/values-pt-rBR/translations.xml b/features/space/impl/src/main/res/values-pt-rBR/translations.xml
index 3329be1097..c509b8caf8 100644
--- a/features/space/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/space/impl/src/main/res/values-pt-rBR/translations.xml
@@ -10,6 +10,7 @@
"Você não será removido das seguintes salas porque você é o único administrador:"
"Sair de %1$s?"
"Você é o único administrador de %1$s"
+ "Ver membros"
"Sair do espaço"
"Cargos e permissões"
"Segurança e privacidade"
diff --git a/features/space/impl/src/main/res/values-ro/translations.xml b/features/space/impl/src/main/res/values-ro/translations.xml
index 588518a249..7640d873a8 100644
--- a/features/space/impl/src/main/res/values-ro/translations.xml
+++ b/features/space/impl/src/main/res/values-ro/translations.xml
@@ -11,6 +11,7 @@
"Nu veți părăsi următoarele camere deoarece sunteți singurul administrator:"
"Părăsiți %1$s?"
"Sunteți singurul administrator pentru %1$s"
+ "Vizualizați membrii"
"Părăsiți spațiul"
"Roluri și permisiuni"
"Securitate & confidențialitate"
diff --git a/features/space/impl/src/main/res/values-ru/translations.xml b/features/space/impl/src/main/res/values-ru/translations.xml
index 47cd467725..090c551fd5 100644
--- a/features/space/impl/src/main/res/values-ru/translations.xml
+++ b/features/space/impl/src/main/res/values-ru/translations.xml
@@ -11,6 +11,7 @@
"Вы не будете удалены из следующих комнат, поскольку вы являетесь единственным администратором:"
"Выйти из %1$s?"
"Вы единственный администратор для %1$s"
+ "Просмотреть участников"
"Покинуть пространство"
"Роли и разрешения"
"Безопасность и конфиденциальность"
diff --git a/features/space/impl/src/main/res/values-sk/translations.xml b/features/space/impl/src/main/res/values-sk/translations.xml
index 2fd11ba58b..79b8fbfbb6 100644
--- a/features/space/impl/src/main/res/values-sk/translations.xml
+++ b/features/space/impl/src/main/res/values-sk/translations.xml
@@ -11,6 +11,7 @@
"Z nasledujúcich miestností nebudete odstránený/á, pretože ste jediným správcom:"
"Opustiť %1$s?"
"Ste jediným administrátorom pre %1$s"
+ "Zobraziť členov"
"Opustiť priestor"
"Roly a povolenia"
"Bezpečnosť a súkromie"
diff --git a/features/space/impl/src/main/res/values-zh-rTW/translations.xml b/features/space/impl/src/main/res/values-zh-rTW/translations.xml
index abf495860f..54da45642c 100644
--- a/features/space/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/space/impl/src/main/res/values-zh-rTW/translations.xml
@@ -9,6 +9,7 @@
"您不會被從以下聊天室移除,因為您是唯一的管理員:"
"離開 %1$s?"
"您是 %1$s 唯一的管理員"
+ "檢視成員"
"離開空間"
"角色與權限"
"安全與隱私"
diff --git a/features/space/impl/src/main/res/values-zh/translations.xml b/features/space/impl/src/main/res/values-zh/translations.xml
index f0afff02f4..ea7011c942 100644
--- a/features/space/impl/src/main/res/values-zh/translations.xml
+++ b/features/space/impl/src/main/res/values-zh/translations.xml
@@ -9,6 +9,7 @@
"您不会从以下房间中被移除,因为您是唯一的管理员:"
"离开%1$s?"
"您是 %1$s 的唯一管理员"
+ "查看成员"
"离开空间"
"角色与权限"
"安全与隐私"
diff --git a/features/space/impl/src/main/res/values/localazy.xml b/features/space/impl/src/main/res/values/localazy.xml
index a4df5e767d..10aa0fb28c 100644
--- a/features/space/impl/src/main/res/values/localazy.xml
+++ b/features/space/impl/src/main/res/values/localazy.xml
@@ -10,6 +10,12 @@
"You will not be removed from the following room(s) because you\'re the only administrator:"
"Leave %1$s?"
"You are the only admin for %1$s"
+ "View members"
+ "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security."
+
+ - "Remove %1$d room from %2$s"
+ - "Remove %1$d rooms from %2$s"
+
"Leave space"
"Roles & permissions"
"Security & privacy"
diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt
index 917aceb262..811e9158b9 100644
--- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt
+++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt
@@ -22,20 +22,24 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
+import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
+import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
+import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -59,7 +63,7 @@ class SpacePresenterTest {
val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test {
val state = awaitItem()
- assertThat(state.currentSpace).isNull()
+ assertThat(state.spaceInfo).isNotNull()
assertThat(state.children).isEmpty()
assertThat(state.seenSpaceInvites).isEmpty()
assertThat(state.hideInvitesAvatar).isFalse()
@@ -139,23 +143,6 @@ class SpacePresenterTest {
}
}
- @Test
- fun `present - current space value`() = runTest {
- val paginateResult = lambdaRecorder> {
- Result.success(Unit)
- }
- val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
- val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
- presenter.test {
- val state = awaitItem()
- advanceUntilIdle()
- assertThat(state.currentSpace).isNull()
- val aSpace = aSpaceRoom()
- spaceRoomList.emitCurrentSpace(aSpace)
- assertThat(awaitItem().currentSpace).isEqualTo(aSpace)
- }
- }
-
@Test
fun `present - children value`() = runTest {
val paginateResult = lambdaRecorder> {
@@ -353,6 +340,216 @@ class SpacePresenterTest {
}
}
+ @Test
+ fun `present - enter manage mode`() = runTest {
+ val presenter = createSpacePresenter()
+ presenter.test {
+ val state = awaitItem()
+ assertThat(state.isManageMode).isFalse()
+ state.eventSink(SpaceEvents.EnterManageMode)
+ val manageModeState = awaitItem()
+ assertThat(manageModeState.isManageMode).isTrue()
+ assertThat(manageModeState.selectedRoomIds).isEmpty()
+ }
+ }
+
+ @Test
+ fun `present - exit manage mode clears selection`() = runTest {
+ val presenter = createSpacePresenter()
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(SpaceEvents.EnterManageMode)
+ initialState.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
+ initialState.eventSink(SpaceEvents.ExitManageMode)
+ val finalState = expectMostRecentItem()
+ assertThat(finalState.isManageMode).isFalse()
+ assertThat(finalState.selectedRoomIds).isEmpty()
+ }
+ }
+
+ @Test
+ fun `present - toggle room selection`() = runTest {
+ val presenter = createSpacePresenter()
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(SpaceEvents.EnterManageMode)
+ // Select a room
+ initialState.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
+ var latestState = expectMostRecentItem()
+ assertThat(latestState.selectedRoomIds).containsExactly(A_ROOM_ID)
+ // Deselect the room
+ latestState.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
+ latestState = expectMostRecentItem()
+ assertThat(latestState.selectedRoomIds).isEmpty()
+ }
+ }
+
+ @Test
+ fun `present - remove rooms success`() = runTest {
+ val removeChildFromSpaceResult = lambdaRecorder> { _, _ ->
+ Result.success(Unit)
+ }
+ val aRoom = aSpaceRoom(
+ roomId = A_ROOM_ID,
+ roomType = RoomType.Room,
+ )
+ val fakeSpaceRoomList = FakeSpaceRoomList(
+ initialSpaceRoomsValue = listOf(aRoom),
+ paginateResult = { Result.success(Unit) },
+ )
+ val presenter = createSpacePresenter(
+ spaceRoomList = fakeSpaceRoomList,
+ spaceService = FakeSpaceService(
+ removeChildFromSpaceResult = removeChildFromSpaceResult,
+ ),
+ )
+ presenter.test {
+ awaitItem() // Initial empty state
+ advanceUntilIdle()
+ val stateWithChildren = awaitItem()
+ assertThat(stateWithChildren.children).hasSize(1)
+ stateWithChildren.eventSink(SpaceEvents.EnterManageMode)
+ stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
+ stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms)
+ stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval)
+ advanceUntilIdle()
+ val successState = expectMostRecentItem()
+ assertThat(successState.removeRoomsAction).isEqualTo(AsyncAction.Success(Unit))
+ assertThat(successState.isManageMode).isFalse()
+ assertThat(successState.children).isEmpty()
+ removeChildFromSpaceResult.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - remove rooms partial failure`() = runTest {
+ val aRoom1 = aSpaceRoom(
+ roomId = A_ROOM_ID,
+ roomType = RoomType.Room,
+ )
+ val aRoom2 = aSpaceRoom(
+ roomId = A_ROOM_ID_2,
+ roomType = RoomType.Room,
+ )
+ val removeChildFromSpaceResult = lambdaRecorder> { _, childId ->
+ if (childId == A_ROOM_ID_2) {
+ Result.failure(AN_EXCEPTION)
+ } else {
+ Result.success(Unit)
+ }
+ }
+ val fakeSpaceRoomList = FakeSpaceRoomList(
+ initialSpaceRoomsValue = listOf(aRoom1, aRoom2),
+ paginateResult = { Result.success(Unit) },
+ )
+ val presenter = createSpacePresenter(
+ spaceRoomList = fakeSpaceRoomList,
+ spaceService = FakeSpaceService(
+ removeChildFromSpaceResult = removeChildFromSpaceResult,
+ ),
+ )
+ presenter.test {
+ awaitItem() // Initial empty state
+ advanceUntilIdle()
+ val stateWithChildren = awaitItem()
+ assertThat(stateWithChildren.children).hasSize(2)
+ stateWithChildren.eventSink(SpaceEvents.EnterManageMode)
+ stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
+ stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID_2))
+ stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms)
+ stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval)
+ advanceUntilIdle()
+ val failureState = expectMostRecentItem()
+ assertThat(failureState.removeRoomsAction.isFailure()).isTrue()
+ // Successfully removed room should be filtered out
+ assertThat(failureState.children.map { it.roomId }).doesNotContain(A_ROOM_ID)
+ // Failed room should still be present
+ assertThat(failureState.children.map { it.roomId }).contains(A_ROOM_ID_2)
+ removeChildFromSpaceResult.assertions().isCalledExactly(2)
+ }
+ }
+
+ @Test
+ fun `present - children filtered in manage mode shows only rooms`() = runTest {
+ val aRoom = aSpaceRoom(
+ roomId = A_ROOM_ID,
+ roomType = RoomType.Room,
+ )
+ val aSubSpace = aSpaceRoom(
+ roomId = A_ROOM_ID_2,
+ roomType = RoomType.Space,
+ )
+ val fakeSpaceRoomList = FakeSpaceRoomList(
+ initialSpaceRoomsValue = listOf(aRoom, aSubSpace),
+ paginateResult = { Result.success(Unit) },
+ )
+ val presenter = createSpacePresenter(spaceRoomList = fakeSpaceRoomList)
+ presenter.test {
+ awaitItem() // Initial empty state
+ advanceUntilIdle()
+ val stateWithChildren = awaitItem()
+ // Both room and space visible initially
+ assertThat(stateWithChildren.children).hasSize(2)
+ assertThat(stateWithChildren.isManageMode).isFalse()
+ stateWithChildren.eventSink(SpaceEvents.EnterManageMode)
+ val manageModeState = expectMostRecentItem()
+ // Only rooms visible in manage mode
+ assertThat(manageModeState.children).hasSize(1)
+ assertThat(manageModeState.children.first().roomId).isEqualTo(A_ROOM_ID)
+ assertThat(manageModeState.children.first().isSpace).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - removed rooms persist after flow update`() = runTest {
+ val removeChildFromSpaceResult = lambdaRecorder> { _, _ ->
+ Result.success(Unit)
+ }
+ val aRoom1 = aSpaceRoom(
+ roomId = A_ROOM_ID,
+ roomType = RoomType.Room,
+ )
+ val aRoom2 = aSpaceRoom(
+ roomId = A_ROOM_ID_2,
+ roomType = RoomType.Room,
+ )
+ val aRoom3 = aSpaceRoom(
+ roomId = A_ROOM_ID_3,
+ roomType = RoomType.Room,
+ )
+ val spaceRoomList = FakeSpaceRoomList(
+ initialSpaceRoomsValue = listOf(aRoom1, aRoom2),
+ paginateResult = { Result.success(Unit) },
+ )
+ val presenter = createSpacePresenter(
+ spaceRoomList = spaceRoomList,
+ spaceService = FakeSpaceService(
+ removeChildFromSpaceResult = removeChildFromSpaceResult,
+ ),
+ )
+ presenter.test {
+ awaitItem() // Initial empty state
+ advanceUntilIdle()
+ val stateWithChildren = awaitItem()
+ stateWithChildren.eventSink(SpaceEvents.EnterManageMode)
+ stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
+ stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms)
+ stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval)
+ advanceUntilIdle()
+ val successState = expectMostRecentItem()
+ assertThat(successState.children.map { it.roomId }).doesNotContain(A_ROOM_ID)
+ // Emit new flow update with a new room added (simulating server refresh)
+ spaceRoomList.emitSpaceRooms(listOf(aRoom1, aRoom2, aRoom3))
+ advanceUntilIdle()
+ val afterFlowUpdate = awaitItem()
+ // A_ROOM_ID should still be filtered out even though it's in the new emission
+ assertThat(afterFlowUpdate.children.map { it.roomId }).doesNotContain(A_ROOM_ID)
+ // But the other rooms should be present
+ assertThat(afterFlowUpdate.children.map { it.roomId }).contains(A_ROOM_ID_2)
+ assertThat(afterFlowUpdate.children.map { it.roomId }).contains(A_ROOM_ID_3)
+ }
+ }
+
private fun TestScope.createSpacePresenter(
client: MatrixClient = FakeMatrixClient(),
room: BaseRoom = FakeBaseRoom(),
@@ -365,6 +562,7 @@ class SpacePresenterTest {
),
acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() },
spaceSettingsEnabled: Boolean = false,
+ spaceService: FakeSpaceService = FakeSpaceService(),
): SpacePresenter {
return SpacePresenter(
client = client,
@@ -379,6 +577,7 @@ class SpacePresenterTest {
FeatureFlags.SpaceSettings.key to spaceSettingsEnabled,
)
),
+ spaceService = spaceService,
)
}
}
diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt
index 440ec1b6a5..65bb740541 100644
--- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt
+++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt
@@ -10,17 +10,19 @@ package io.element.android.features.space.impl.root
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
+import io.element.android.libraries.previewutils.room.aSpaceRoom
import org.junit.Test
class SpaceStateTest {
@Test
fun `test default state`() {
val state = aSpaceState()
- assertThat(state.hasAnyFailure).isFalse()
+ assertThat(state.hasAnyJoinFailures).isFalse()
assertThat(state.isJoining(A_ROOM_ID)).isFalse()
}
@@ -33,7 +35,7 @@ class SpaceStateTest {
A_ROOM_ID_3 to AsyncAction.Success(Unit),
)
)
- assertThat(state.hasAnyFailure).isTrue()
+ assertThat(state.hasAnyJoinFailures).isTrue()
}
@Test
@@ -45,4 +47,80 @@ class SpaceStateTest {
)
assertThat(state.isJoining(A_ROOM_ID)).isTrue()
}
+
+ @Test
+ fun `test isSelected returns true for selected room`() {
+ val state = aSpaceState(
+ selectedRoomIds = setOf(A_ROOM_ID)
+ )
+ assertThat(state.isSelected(A_ROOM_ID)).isTrue()
+ }
+
+ @Test
+ fun `test isSelected returns false for non-selected room`() {
+ val state = aSpaceState(
+ selectedRoomIds = setOf(A_ROOM_ID)
+ )
+ assertThat(state.isSelected(A_ROOM_ID_2)).isFalse()
+ }
+
+ @Test
+ fun `test showManageRoomsAction true when canManageRooms and has room children`() {
+ val state = aSpaceState(
+ canManageRooms = true,
+ children = listOf(aSpaceRoom(roomType = RoomType.Room))
+ )
+ assertThat(state.showManageRoomsAction).isTrue()
+ }
+
+ @Test
+ fun `test showManageRoomsAction false when canManageRooms but children empty`() {
+ val state = aSpaceState(
+ canManageRooms = true,
+ children = emptyList()
+ )
+ assertThat(state.showManageRoomsAction).isFalse()
+ }
+
+ @Test
+ fun `test showManageRoomsAction false when canManageRooms but only space children`() {
+ val state = aSpaceState(
+ canManageRooms = true,
+ children = listOf(aSpaceRoom(roomType = RoomType.Space))
+ )
+ assertThat(state.showManageRoomsAction).isFalse()
+ }
+
+ @Test
+ fun `test showManageRoomsAction false when has room children but canManageRooms false`() {
+ val state = aSpaceState(
+ canManageRooms = false,
+ children = listOf(aSpaceRoom(roomType = RoomType.Room))
+ )
+ assertThat(state.showManageRoomsAction).isFalse()
+ }
+
+ @Test
+ fun `test selectedCount returns correct count`() {
+ val state = aSpaceState(
+ selectedRoomIds = setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
+ )
+ assertThat(state.selectedCount).isEqualTo(3)
+ }
+
+ @Test
+ fun `test isRemoveButtonEnabled true when selectedRoomIds not empty`() {
+ val state = aSpaceState(
+ selectedRoomIds = setOf(A_ROOM_ID)
+ )
+ assertThat(state.isRemoveButtonEnabled).isTrue()
+ }
+
+ @Test
+ fun `test isRemoveButtonEnabled false when selectedRoomIds empty`() {
+ val state = aSpaceState(
+ selectedRoomIds = emptySet()
+ )
+ assertThat(state.isRemoveButtonEnabled).isFalse()
+ }
}
diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt
index 406b5d17e8..27970e93f8 100644
--- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt
+++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt
@@ -15,11 +15,13 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_ROOM_TOPIC
+import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -29,6 +31,7 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
+import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@@ -124,7 +127,7 @@ class SpaceViewTest {
val eventsRecorder = EventsRecorder()
rule.setSpaceView(
aSpaceState(
- parentSpace = aSpaceRoom(topic = A_ROOM_TOPIC),
+ spaceInfo = aRoomInfo(topic = A_ROOM_TOPIC),
hasMoreToLoad = false,
eventSink = eventsRecorder,
)
@@ -132,6 +135,71 @@ class SpaceViewTest {
rule.onNodeWithText(A_ROOM_TOPIC).performClick()
eventsRecorder.assertSingle(SpaceEvents.ShowTopicViewer(A_ROOM_TOPIC))
}
+
+ @Test
+ fun `clicking back in manage mode emits ExitManageMode event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setSpaceView(
+ aSpaceState(
+ hasMoreToLoad = false,
+ isManageMode = true,
+ eventSink = eventsRecorder,
+ )
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertSingle(SpaceEvents.ExitManageMode)
+ }
+
+ @Test
+ fun `clicking on room in manage mode emits ToggleRoomSelection event`() {
+ val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, displayName = A_ROOM_NAME)
+ val eventsRecorder = EventsRecorder()
+ rule.setSpaceView(
+ aSpaceState(
+ children = listOf(aSpaceRoom),
+ hasMoreToLoad = false,
+ isManageMode = true,
+ eventSink = eventsRecorder,
+ )
+ )
+ rule.onNodeWithText(A_ROOM_NAME).performClick()
+ eventsRecorder.assertSingle(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
+ }
+
+ @Test
+ fun `clicking remove button emits RemoveSelectedRooms event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setSpaceView(
+ aSpaceState(
+ children = listOf(aSpaceRoom(roomId = A_ROOM_ID)),
+ hasMoreToLoad = false,
+ isManageMode = true,
+ selectedRoomIds = setOf(A_ROOM_ID),
+ eventSink = eventsRecorder,
+ )
+ )
+ rule.clickOn(CommonStrings.action_remove)
+ eventsRecorder.assertSingle(SpaceEvents.RemoveSelectedRooms)
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking confirm in removal dialog emits ConfirmRoomRemoval event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setSpaceView(
+ aSpaceState(
+ children = listOf(aSpaceRoom(roomId = A_ROOM_ID)),
+ hasMoreToLoad = false,
+ isManageMode = true,
+ selectedRoomIds = setOf(A_ROOM_ID),
+ removeRoomsAction = AsyncAction.ConfirmingNoParams,
+ eventSink = eventsRecorder,
+ )
+ )
+ // Click on the Remove button in the confirmation dialog
+ rule.clickOn(CommonStrings.action_remove, inDialog = true)
+ eventsRecorder.assertSingle(SpaceEvents.ConfirmRoomRemoval)
+ }
}
private fun AndroidComposeTestRule.setSpaceView(
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 14dde810a9..216d9ecd75 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -22,7 +22,7 @@ camera = "1.5.2"
work = "2.11.0"
# Compose
-compose_bom = "2025.12.01"
+compose_bom = "2026.01.00"
# Coroutines
coroutines = "1.10.2"
@@ -78,7 +78,7 @@ kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlin
kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
# https://firebase.google.com/docs/android/setup#available-libraries
-google_firebase_bom = "com.google.firebase:firebase-bom:34.7.0"
+google_firebase_bom = "com.google.firebase:firebase-bom:34.8.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
@@ -109,7 +109,7 @@ androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "medi
androidx_media3_transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3" }
androidx_media3_effect = { module = "androidx.media3:media3-effect", version.ref = "media3" }
androidx_media3_common = { module = "androidx.media3:media3-common", version.ref = "media3" }
-androidx_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
+androidx_biometric = "androidx.biometric:biometric-ktx:1.4.0-alpha02"
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
@@ -219,7 +219,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0"
# Analytics
posthog = "com.posthog:posthog-android:3.28.1"
-sentry = "io.sentry:sentry-android:8.29.0"
+sentry = "io.sentry:sentry-android:8.30.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.29.2"
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
index d3a2805df1..d13777842d 100644
--- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
@@ -100,6 +100,8 @@ fun String.containsRtLOverride() = contains(RTL_OVERRIDE_CHAR)
fun String.filterDirectionOverrides() = filterNot { it == RTL_OVERRIDE_CHAR || it == LTR_OVERRIDE_CHAR }
+const val DEFAULT_SAFE_LENGTH = 500
+
/**
* This works around https://github.com/element-hq/element-x-android/issues/2105.
* @param maxLength Max characters to retrieve. Defaults to `500`.
@@ -107,7 +109,7 @@ fun String.filterDirectionOverrides() = filterNot { it == RTL_OVERRIDE_CHAR || i
* @return The string truncated to [maxLength] characters, with an optional ellipsis if larger.
*/
fun String.toSafeLength(
- maxLength: Int = 500,
+ maxLength: Int = DEFAULT_SAFE_LENGTH,
ellipsize: Boolean = false,
): String {
return if (ellipsize) {
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt
index 7e1ffc3ee6..009547f9eb 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt
@@ -9,6 +9,7 @@
package io.element.android.libraries.eventformatter.impl
import dev.zacsweers.metro.ContributesBinding
+import io.element.android.libraries.core.extensions.DEFAULT_SAFE_LENGTH
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
@@ -54,11 +55,6 @@ class DefaultRoomLatestEventFormatter(
private val stateContentFormatter: StateContentFormatter,
private val permalinkParser: PermalinkParser,
) : RoomLatestEventFormatter {
- companion object {
- // Max characters to display in the last message. This works around https://github.com/element-hq/element-x-android/issues/2105
- private const val MAX_SAFE_LENGTH = 500
- }
-
override fun format(
latestEvent: LatestEventValue.Local,
isDmRoom: Boolean,
@@ -121,7 +117,7 @@ class DefaultRoomLatestEventFormatter(
}
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call)
is CallNotifyContent -> sp.getString(CommonStrings.common_call_started)
- }?.take(MAX_SAFE_LENGTH)
+ }?.take(DEFAULT_SAFE_LENGTH)
}
private fun MessageContent.process(
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index a77e09711f..4422330924 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -133,4 +133,11 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
+ SignInWithClassic(
+ key = "feature.signin_with_classic",
+ title = "Sign in with Element Classic",
+ description = "Allow the application to sign in to the current Element Classic account.",
+ defaultValue = { false },
+ isFinished = false,
+ ),
}
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 dca269453a..773dbaaa07 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
@@ -214,7 +214,15 @@ interface MatrixClient {
*/
fun createLinkDesktopHandler(): Result
+ /**
+ * Performs a database optimization that should flush cached data and improve performance.
+ */
suspend fun performDatabaseVacuum(): Result
+
+ /**
+ * Resets the cached client `well-known` config by the SDK.
+ */
+ suspend fun resetWellKnownConfig(): Result
}
/**
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt
index 8f005b0225..927bec13e1 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt
@@ -22,4 +22,12 @@ interface SpaceService {
fun spaceRoomList(id: RoomId): SpaceRoomList
fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle
+
+ /**
+ * Remove a child room from a space.
+ * @param spaceId The space ID from which to remove the child.
+ * @param childId The room ID of the child to remove.
+ * @return A result indicating success or failure.
+ */
+ suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result
}
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 f17f92517a..b24f4b90d8 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
@@ -791,6 +791,13 @@ class RustMatrixClient(
}
}
+ override suspend fun resetWellKnownConfig(): Result {
+ return runCatchingExceptions {
+ Timber.d("Resetting well-known config for session $sessionId")
+ innerClient.resetWellKnown()
+ }
+ }
+
private suspend fun getCacheSize(
includeCryptoDb: Boolean = false,
): Long = withContext(sessionDispatcher) {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt
index 95eaefb671..2ce184484c 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt
@@ -98,6 +98,12 @@ class RustSpaceService(
}
}
+ override suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result = withContext(sessionDispatcher) {
+ runCatchingExceptions {
+ innerSpaceService.removeChildFromSpace(childId = childId.value, spaceId = spaceId.value)
+ }
+ }
+
init {
innerSpaceService
.spaceListUpdate()
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 a740e71b0c..56527574d7 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
@@ -112,6 +112,7 @@ class FakeMatrixClient(
private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result = { _, _ -> lambdaError() },
private val performDatabaseVacuumLambda: () -> Result = { lambdaError() },
private val getDatabaseSizesLambda: () -> Result = { lambdaError() },
+ private val resetWellKnownConfigLambda: () -> Result = { lambdaError() },
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@@ -379,4 +380,8 @@ class FakeMatrixClient(
override fun createLinkMobileHandler(): Result {
return createLinkMobileHandlerResult()
}
+
+ override suspend fun resetWellKnownConfig(): Result {
+ return resetWellKnownConfigLambda()
+ }
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt
index d796c0b538..0cd9907db3 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt
@@ -23,6 +23,7 @@ class FakeSpaceService(
private val joinedSpacesResult: () -> Result> = { lambdaError() },
private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() },
private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() },
+ private val removeChildFromSpaceResult: (RoomId, RoomId) -> Result = { _, _ -> lambdaError() },
private val joinedParentsResult: (RoomId) -> Result> = { lambdaError() },
private val getSpaceRoomResult: (RoomId) -> SpaceRoom? = { lambdaError() },
) : SpaceService {
@@ -53,4 +54,8 @@ class FakeSpaceService(
override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle {
return leaveSpaceHandleResult(spaceId)
}
+
+ override suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result = simulateLongTask {
+ removeChildFromSpaceResult(spaceId, childId)
+ }
}
diff --git a/libraries/ui-strings/src/main/res/values-bg/translations.xml b/libraries/ui-strings/src/main/res/values-bg/translations.xml
index 483c47fbea..d6e314044b 100644
--- a/libraries/ui-strings/src/main/res/values-bg/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-bg/translations.xml
@@ -333,7 +333,6 @@
"Споделяне на това местоположение"
"%1$s пространство"
"Пространства"
- "Преглед на членовете"
"Местоположение"
"Версия: %1$s (%2$s)"
"bg"
diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml
index 45823e3a0d..fafb59d4fb 100644
--- a/libraries/ui-strings/src/main/res/values-cs/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml
@@ -483,7 +483,6 @@ Opravdu chcete pokračovat?"
"%1$s • %2$s"
"%1$s prostor"
"Prostory"
- "Zobrazit členy"
"Zpráva nebyla odeslána, protože ověřená identita uživatele %1$s se změnila."
"Zpráva nebyla odeslána, protože%1$s neověřil(a) všechna zařízení."
"Zpráva nebyla odeslána, protože jste neověřili jedno nebo více zařízení."
diff --git a/libraries/ui-strings/src/main/res/values-da/translations.xml b/libraries/ui-strings/src/main/res/values-da/translations.xml
index 6a230d4dfe..6dc36fac5a 100644
--- a/libraries/ui-strings/src/main/res/values-da/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-da/translations.xml
@@ -469,7 +469,6 @@ Er du sikker på, at du vil fortsætte?"
"%1$s•%2$s"
"%1$s gruppe"
"Grupper"
- "Vis medlemmer"
"Beskeden blev ikke sendt fordi %1$s s bekræftede identitet blev nulstillet."
"Meddelelsen er ikke sendt, fordi %1$s ikke har bekræftet alle enheder."
"Beskeden er ikke sendt, fordi du ikke har verificeret en eller flere af dine enheder."
diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml
index 05650799ec..60a77143d6 100644
--- a/libraries/ui-strings/src/main/res/values-de/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-de/translations.xml
@@ -483,12 +483,6 @@ Möchtest du wirklich fortfahren?"
"Erstelle einen Space, um Chats zu organisieren"
"%1$s Space"
"Spaces"
- "Mitglieder anzeigen"
- "Das Entfernen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\""
-
- - "Chat aus %1$s entfernen"
- - "%1$d chats aus %2$s entfernen"
-
"Nachricht nicht gesendet, weil sich die verifizierte Identität von %1$s geändert hat."
"Die Nachricht wurde nicht gesendet, weil %1$s nicht alle Geräte verifiziert hat."
"Die Nachricht wurde nicht gesendet, weil du eines oder mehrere deiner Geräte nicht verifiziert hast."
diff --git a/libraries/ui-strings/src/main/res/values-et/translations.xml b/libraries/ui-strings/src/main/res/values-et/translations.xml
index 6e344cd6b2..9bd2ef325c 100644
--- a/libraries/ui-strings/src/main/res/values-et/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-et/translations.xml
@@ -476,7 +476,6 @@ Kas sa oled kindel, et soovid jätkata?"
"%1$s • %2$s"
"Kogukond: %1$s"
"Kogukonnad"
- "Vaata liikmeid"
"Sõnum on saatmata, kuna kasutaja %1$s verifitseeritud identiteet on lähtestatud."
"Sõnum on saatmata, kuna %1$s pole verifitseerinud kõiki oma seadmeid."
"Kuna sa pole üks või enamgi oma seadet verifitseerinud, siis sinu sõnum on saatmata."
diff --git a/libraries/ui-strings/src/main/res/values-fa/translations.xml b/libraries/ui-strings/src/main/res/values-fa/translations.xml
index 16872a2e91..083f84ddc6 100644
--- a/libraries/ui-strings/src/main/res/values-fa/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-fa/translations.xml
@@ -400,7 +400,6 @@
"%1$s • %2$s"
"%1$s فضا"
"فضاها"
- "دیدن اعضا"
"مکان"
"نگارش : %1$s (%2$s)"
"fa"
diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml
index 4aa4797e3c..7d71dca9ef 100644
--- a/libraries/ui-strings/src/main/res/values-fi/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml
@@ -470,7 +470,6 @@ Haluatko varmasti jatkaa?"
"%1$s • %2$s"
"%1$s tila"
"Tilat"
- "Näytä jäsenet"
"Viestiä ei lähetetty, koska käyttäjän %1$s vahvistettu identiteetti nollattiin."
"Viestiä ei lähetetty, koska %1$s ei ole vahvistanut kaikkia laitteitaan."
"Viestiä ei lähetetty, koska et ole vahvistanut yhtä tai useampaa laitettasi."
diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml
index 46e0b3dfb1..be99a39c7d 100644
--- a/libraries/ui-strings/src/main/res/values-fr/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml
@@ -483,7 +483,6 @@ Raison : %1$s."
"Créer des espaces pour organiser les salons"
"Espace %1$s"
"Espaces"
- "Voir les membres"
"Le message n’a pas été envoyé car l’identité vérifiée de %1$s a été réinitialisée."
"Le message n’a pas été envoyé car %1$s n’a pas vérifié tous ses appareils."
"Message non envoyé car vous n’avez pas vérifié tous vos appareils."
diff --git a/libraries/ui-strings/src/main/res/values-hr/translations.xml b/libraries/ui-strings/src/main/res/values-hr/translations.xml
index 20aa4a2f66..2d9676c36a 100644
--- a/libraries/ui-strings/src/main/res/values-hr/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-hr/translations.xml
@@ -485,7 +485,6 @@ Jeste li sigurni da želite nastaviti?"
"%1$s • %2$s"
"Prostor %1$s"
"Prostori"
- "Prikaži članove"
"Poruka nije poslana jer je poništen potvrđeni identitet korisnika %1$s."
"Poruka nije poslana jer %1$s nije potvrdio sve uređaje."
"Poruka nije poslana jer niste potvrdili jedan svoj uređaj ili više njih."
diff --git a/libraries/ui-strings/src/main/res/values-hu/translations.xml b/libraries/ui-strings/src/main/res/values-hu/translations.xml
index be1cc82207..259ef3dd15 100644
--- a/libraries/ui-strings/src/main/res/values-hu/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-hu/translations.xml
@@ -469,7 +469,6 @@ Biztos, hogy folytatja?"
"%1$s • %2$s"
"%1$s tér"
"Terek"
- "Tagok megtekintése"
"Az üzenet nem lett elküldve, mert %1$s ellenőrzött személyazonossága megváltozott."
"Az üzenet nem lett elküldve, mert %1$s nem ellenőrizte az összes eszközét."
"Az üzenet nem lett elküldve, mert egy vagy több eszközét nem ellenőrizte."
diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml
index 01eb441661..c309732f11 100644
--- a/libraries/ui-strings/src/main/res/values-it/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-it/translations.xml
@@ -470,7 +470,6 @@ Sei sicuro di voler continuare?"
"%1$s • %2$s"
"%1$s spazio"
"Spazi"
- "Visualizza membri"
"Messaggio non inviato perché l\'identità verificata di %1$s è stata reimpostata."
"Messaggio non inviato perché %1$s non ha verificato tutti i dispositivi."
"Messaggio non inviato perché non hai verificato uno o più dispositivi."
diff --git a/libraries/ui-strings/src/main/res/values-nb/translations.xml b/libraries/ui-strings/src/main/res/values-nb/translations.xml
index 09d3eab84c..05673571c6 100644
--- a/libraries/ui-strings/src/main/res/values-nb/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-nb/translations.xml
@@ -467,7 +467,6 @@ Er du sikker på at du vil fortsette?"
"%1$s • %2$s"
"%1$s område"
"Områder"
- "Vis medlemmer"
"Meldingen ble ikke sendt fordi %1$ss verifiserte identitet er tilbakestilt."
"Meldingen ble ikke sendt fordi %1$s ikke har verifisert alle enheter."
"Meldingen ble ikke sendt fordi du ikke har verifisert en eller flere av enhetene dine."
diff --git a/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml
index 74acd35b3e..3c87408694 100644
--- a/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml
@@ -479,7 +479,6 @@ Você tem certeza de que deseja continuar?"
"%1$s • %2$s"
"Espaço %1$s"
"Espaços"
- "Ver membros"
"Mensagem não enviada porque a identidade verificada de %1$s foi redefinida."
"A mensagem não foi enviada porque %1$s não verificou todos os dispositivos."
"Mensagem não enviada porque você não verificou um ou mais dos seus dispositivos."
diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml
index e8727a98ae..0851be7648 100644
--- a/libraries/ui-strings/src/main/res/values-ro/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml
@@ -484,7 +484,6 @@ Sunteți sigur că doriți să continuați?"
"%1$s • %2$s"
"Spațiu %1$s"
"Spații"
- "Vizualizați membrii"
"Mesajul nu a fost trimis deoarece identitatea verificată a lui %1$s s-a schimbat."
"Mesajul nu a fost trimis deoarece %1$s nu a verificat toate dispozitivele."
"Mesajul nu a fost trimis deoarece nu ați verificat unul sau mai multe dispozitive."
diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml
index 77dde7078d..1078a92fd6 100644
--- a/libraries/ui-strings/src/main/res/values-ru/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml
@@ -479,7 +479,6 @@
"%1$s • %2$s"
"%1$s пространство"
"Пространства"
- "Просмотреть участников"
"Сообщение не отправлено, потому что подтвержденная личность %1$s была сброшена."
"Сообщение не отправлено, потому что %1$s не проверил одно или несколько устройств."
"Сообщение не отправлено, поскольку вы не подтвердили одно или несколько своих устройств."
diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml
index 747ac45132..e209bd0a21 100644
--- a/libraries/ui-strings/src/main/res/values-sk/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml
@@ -481,7 +481,6 @@ Naozaj chcete pokračovať?"
"%1$s • %2$s"
"%1$s priestor"
"Priestory"
- "Zobraziť členov"
"Správa nebola odoslaná, pretože sa zmenila overená totožnosť používateľa %1$s."
"Správa nebola odoslaná, pretože %1$s neoveril/a všetky zariadenia."
"Správa nebola odoslaná, pretože ste neoverili jedno alebo viac svojich zariadení."
diff --git a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml
index 67815cfbc6..e70937357d 100644
--- a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml
@@ -461,7 +461,6 @@
"%1$s • %2$s"
"%1$s 空間"
"空間"
- "檢視成員"
"因為 %1$s 的驗證身份已重設,因此未傳送訊息。"
"訊息未傳送,因為 %1$s 尚未驗證所有裝置。"
"因為您尚未驗證一個或多個裝置,因此未傳送訊息"
diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml
index 774a3d3f89..7d63c0ccb9 100644
--- a/libraries/ui-strings/src/main/res/values-zh/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml
@@ -460,7 +460,6 @@
"%1$s • %2$s"
"%1$s空间"
"空间"
- "查看成员"
"消息未发送,因为%1$s的已验证身份已被重置。"
"消息未发送,因为%1$s尚未验证所有设备。"
"消息未发送,因为您有尚未验证的设备。"
diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml
index 9a86b9c203..70dd613526 100644
--- a/libraries/ui-strings/src/main/res/values/localazy.xml
+++ b/libraries/ui-strings/src/main/res/values/localazy.xml
@@ -317,6 +317,10 @@ Reason: %1$s."
"Security"
"Seen by"
"Select an account"
+
+ - "%1$d selected"
+ - "%1$d selected"
+
"Send to"
"Sending…"
"Sending failed"
@@ -484,12 +488,6 @@ Are you sure you want to continue?"
"Create spaces to organize rooms"
"%1$s space"
"Spaces"
- "View members"
- "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security."
-
- - "Remove room from %1$s"
- - "Remove %1$d rooms from %2$s"
-
"Message not sent because %1$s’s verified identity was reset."
"Message not sent because %1$s has not verified all devices."
"Message not sent because you have not verified one or more of your devices."
diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt
index 8ae9e90dad..4b5f9c9c50 100644
--- a/plugins/src/main/kotlin/Versions.kt
+++ b/plugins/src/main/kotlin/Versions.kt
@@ -45,7 +45,7 @@ private const val versionMonth = 1
* Release number in the month. Value must be in [0,99].
* Do not update this value. it is updated by the release script.
*/
-private const val versionReleaseNumber = 0
+private const val versionReleaseNumber = 1
object Versions {
/**
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_0_en.png
index 0f62df3d20..f69878ffd5 100644
--- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c55277089a4447b618a0e8c058718ecf9d3da6d437322f0e23e5fd70019f6b00
-size 34585
+oid sha256:e59a9e2ae6ef36f28e61534b5639314cc840953df51bb1660e77e8d565865357
+size 32998
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_1_en.png
index bb1c9d1947..7f66fa5a85 100644
--- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_1_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:241f5500cb7212fac174466bbe7855ccf39de3e3764a83202388b947d90ae807
-size 34770
+oid sha256:b854ce2b0618ebcbc88eff9952d6869bceeb8b43f9eccb8f8e5feef225e0a4c2
+size 33181
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_2_en.png
index 6f624546ab..b0f6f289a0 100644
--- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_2_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_2_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:92785cf3a4010779b0fbcd58be3437a22808b0a2f02a19a5cfd50eb3bd58ed26
-size 35058
+oid sha256:e1cf063ee5c9fbc50a53445050780ba239d4fb0fd1e9903a578eab8dd3bfc257
+size 33496
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_3_en.png
index 8b5b2f5f27..330b4d2dca 100644
--- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_3_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_3_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:de26882f13bac98b2cb5365d98e06e781d516d179adb8328cf22cf524e6fd79e
-size 62568
+oid sha256:669287557e8effe8154682d33de45430fc852ddefa25ed7399aa3917730e2893
+size 61083
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_4_en.png
index cfa8381d77..53464e9cd5 100644
--- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_4_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_4_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a121fdb9473512b0264e48294df1799a7a6bf9b469df973fbe41f31bbf98f1d0
-size 63248
+oid sha256:d2ef07a1af8f872a5b7bb708314bc180d4eefe9545c763a0860f895af6e3ce37
+size 61755
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_5_en.png
index f20b7c4048..97dbb10528 100644
--- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_5_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_5_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a3506b4f646408262450ae51b612f86c1171ed972c1d7ea8871c4dc090556c7a
-size 59702
+oid sha256:525059397001897f705630b8ac5a661439a502f2e623fdca252f9e86f97133e4
+size 58181
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_6_en.png
new file mode 100644
index 0000000000..bc44bb4ce4
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_6_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:59af097a2152b235c7df83aa76eea880ceef65edebebecb86f00745ec39712f4
+size 35937
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_7_en.png
new file mode 100644
index 0000000000..68f915afff
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_7_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a5151bbd3effcd6a46724a0cfb13730e845a3b8ba489826146c76d0a797403e0
+size 36502
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_8_en.png
new file mode 100644
index 0000000000..64ce2a5fe0
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_8_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2b445ae9e0ff01dea22b8ffb98aaaf4c5593093e2b2bea2ff9508cc43832e687
+size 48283
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_0_en.png
index e8f00148a3..9d406ab88a 100644
--- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f2407444889af236ef21a90c47a5d3e05df8b15b9cc9483e84377e3af8794772
-size 33996
+oid sha256:47c8bdf4d153ecebe749ae3cacfe4c9a1c59ac836106fb0903dca80305aedac2
+size 32412
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_1_en.png
index 6f012ad603..d178c45fec 100644
--- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_1_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:fee41efefc2ca1d6670d8455ac756c6b314aab54510eab8a4e597f1cc1edf3f8
-size 34141
+oid sha256:33917297cb1c6d38e5b955757de50f4ff6b73844bb87e8e88d0885daa01b266b
+size 32556
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_2_en.png
index 24f916eac9..35ca1a2937 100644
--- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_2_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_2_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ea955839cbd1aeba5de2780cee413628c7d46383398b10125cd3a900fb41d5a5
-size 34459
+oid sha256:930f763051533ea3aa4032e483e15e0c1ccb1d213d2e59bebe7f934517b500ba
+size 32868
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_3_en.png
index 153b68c3d0..86af1703e4 100644
--- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_3_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_3_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cba2c99744aeb2a869ae2ed700d7241b1d0b6ed979b16d2be9774ddbc5f8f28a
-size 61381
+oid sha256:e38c4a6ff464f77da95b2aa0eaba647bc54ee590c2c4319478fcd54a2400b886
+size 59877
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_4_en.png
index f36d90e33c..b2628c4761 100644
--- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_4_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_4_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b32d65accabc357208efeb2ec61374182479541299ade28184f82938e59bfdd5
-size 61932
+oid sha256:8d91c27ee6dc7938ce905774868b66157b06944ae7c230935c30d6be8ce189be
+size 60430
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_5_en.png
index 66a7762467..5f2f43a920 100644
--- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_5_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_5_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7c161ff55e8a235fe403e53ee179b299fd2563d85ef64bfe6d0dd9295228685b
-size 57925
+oid sha256:dd673c2cf628285836848a732d3972a17421a2394464a3a77e7a49e4c5a862f1
+size 56636
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_6_en.png
new file mode 100644
index 0000000000..3078485999
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_6_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b459fa8aa195dcf85995ac51c2f079e9d2505efede138bcef94c0a5049e1f271
+size 35266
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_7_en.png
new file mode 100644
index 0000000000..7741306594
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_7_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:36fd123fcff07169723857c99e4e375fe9bbb8193b2a6e97508343ea025d5787
+size 35782
diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_8_en.png
new file mode 100644
index 0000000000..5db2323ea6
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_8_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a5eed7b1690ac5fbd24312e37029ca880b5cf4424e3796b116829074ef891f2d
+size 45433
diff --git a/tools/localazy/config.json b/tools/localazy/config.json
index 3894befcb0..e6ea1df3cb 100644
--- a/tools/localazy/config.json
+++ b/tools/localazy/config.json
@@ -233,7 +233,8 @@
"name" : ":features:space:impl",
"includeRegex" : [
"screen\\.leave_space\\..*",
- "screen\\.space_settings\\..*"
+ "screen\\.space_settings\\..*",
+ "screen\\.space\\..*"
]
},
{