diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index 757dd73395..8dc2de5e4e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -21,6 +21,8 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject import im.vector.app.features.analytics.plan.CryptoSessionStateChange import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.extensions.runCatchingExceptions @@ -56,6 +58,7 @@ class LoggedInPresenter( private val analyticsService: AnalyticsService, private val encryptionService: EncryptionService, private val buildMeta: BuildMeta, + private val networkMonitor: NetworkMonitor, ) : Presenter { @Composable override fun present(): LoggedInState { @@ -107,6 +110,14 @@ class LoggedInPresenter( }.launchIn(this) } + val networkConnectivity by networkMonitor.connectivity.collectAsState() + LaunchedEffect(networkConnectivity) { + if (networkConnectivity == NetworkStatus.Connected) { + // Refresh homeserver capabilities when the network is back + matrixClient.homeserverCapabilities().refresh() + } + } + fun handleEvent(event: LoggedInEvents) { when (event) { is LoggedInEvents.CloseErrorDialog -> { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt index f1759eab3e..d147a4ed68 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt @@ -14,6 +14,8 @@ import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.CryptoSessionStateChange import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId @@ -27,6 +29,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeHomeserverCapabilitiesProvider import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService @@ -109,6 +112,7 @@ class LoggedInPresenterTest { val verificationService = FakeSessionVerificationService() val encryptionService = FakeEncryptionService() val buildMeta = aBuildMeta() + val networkMonitor = FakeNetworkMonitor() LoggedInPresenter( matrixClient = FakeMatrixClient( roomListService = roomListService, @@ -122,6 +126,7 @@ class LoggedInPresenterTest { analyticsService = analyticsService, encryptionService = encryptionService, buildMeta = buildMeta, + networkMonitor = networkMonitor, ).test { encryptionService.emitRecoveryState(RecoveryState.UNKNOWN) encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) @@ -319,6 +324,27 @@ class LoggedInPresenterTest { } } + @Test + fun `present - refreshes homeserver capabilities when network is back`() = runTest { + val refreshLambda = lambdaRecorder> { Result.success(Unit) } + val matrixClient = FakeMatrixClient( + homeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(refresh = refreshLambda), + accountManagementUrlResult = { Result.success(null) }, + ) + val networkMonitor = FakeNetworkMonitor() + createLoggedInPresenter( + matrixClient = matrixClient, + networkMonitor = networkMonitor, + ).test { + awaitItem() + networkMonitor.connectivity.value = NetworkStatus.Connected + + advanceUntilIdle() + + refreshLambda.assertions().isCalledOnce() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { skipItems(1) return awaitItem() @@ -334,6 +360,7 @@ class LoggedInPresenterTest { accountManagementUrlResult = { Result.success(null) }, ), buildMeta: BuildMeta = aBuildMeta(), + networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), ): LoggedInPresenter { return LoggedInPresenter( matrixClient = matrixClient, @@ -343,6 +370,7 @@ class LoggedInPresenterTest { analyticsService = analyticsService, encryptionService = encryptionService, buildMeta = buildMeta, + networkMonitor = networkMonitor, ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt index bddae2fffb..1ab69f6007 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -103,6 +104,14 @@ class EditUserProfilePresenter( } } + val homeserverCapabilities = matrixClient.homeserverCapabilities() + val canChangeDisplayName = produceState(true) { + value = homeserverCapabilities.canChangeDisplayName().getOrDefault(true) + } + val canChangeAvatar = produceState(true) { + value = homeserverCapabilities.canChangeAvatarUrl().getOrDefault(true) + } + val saveAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } val localCoroutineScope = rememberCoroutineScope() @@ -169,6 +178,8 @@ class EditUserProfilePresenter( saveButtonEnabled = canSave && saveAction.value !is AsyncAction.Loading, saveAction = saveAction.value, cameraPermissionState = cameraPermissionState, + canChangeDisplayName = canChangeDisplayName.value, + canChangeAvatarUrl = canChangeAvatar.value, eventSink = ::handleEvent, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt index a638ed8378..a40f1710e2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt @@ -22,5 +22,7 @@ data class EditUserProfileState( val saveButtonEnabled: Boolean, val saveAction: AsyncAction, val cameraPermissionState: PermissionsState, + val canChangeDisplayName: Boolean, + val canChangeAvatarUrl: Boolean, val eventSink: (EditUserProfileEvent) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt index ca9571aea5..13f69a7e1e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt @@ -22,6 +22,7 @@ open class EditUserProfileStateProvider : PreviewParameterProvider = AsyncAction.Uninitialized, cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false), + canChangeDisplayName: Boolean = true, + canChangeAvatarUrl: Boolean = true, eventSink: (EditUserProfileEvent) -> Unit = {}, ) = EditUserProfileState( userId = userId, @@ -42,5 +45,7 @@ fun aEditUserProfileState( saveButtonEnabled = saveButtonEnabled, saveAction = saveAction, cameraPermissionState = cameraPermissionState, + canChangeDisplayName = canChangeDisplayName, + canChangeAvatarUrl = canChangeAvatarUrl, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt index 774dcedae0..d4571d7be5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt @@ -120,6 +120,7 @@ fun EditUserProfileView( state = avatarPickerState, onClick = ::onAvatarClick, modifier = Modifier.align(Alignment.CenterHorizontally), + enabled = state.canChangeAvatarUrl, ) Spacer(modifier = Modifier.height(16.dp)) Text( @@ -134,6 +135,7 @@ fun EditUserProfileView( value = state.displayName, placeholder = stringResource(CommonStrings.common_room_name_placeholder), singleLine = true, + enabled = state.canChangeDisplayName, onValueChange = { state.eventSink(EditUserProfileEvent.UpdateDisplayName(it)) }, ) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/HomeserverCapabilitiesProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/HomeserverCapabilitiesProvider.kt new file mode 100644 index 0000000000..e914656f5f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/HomeserverCapabilitiesProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api + +/** + * Provides information about the capabilities of the homeserver. + * + * Spec: https://spec.matrix.org/latest/client-server-api/#capabilities-negotiation + */ +interface HomeserverCapabilitiesProvider { + /** + * Manually refresh the capabilities of the homeserver performing a network request. + */ + suspend fun refresh(): Result + + /** + * Indicates whether the homeserver allows the user to change their display name. + */ + suspend fun canChangeDisplayName(): Result + + /** + * Indicates whether the homeserver allows the user to change their avatar URL. + */ + suspend fun canChangeAvatarUrl(): Result +} 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 773dbaaa07..35fd7e8551 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 @@ -223,6 +223,8 @@ interface MatrixClient { * Resets the cached client `well-known` config by the SDK. */ suspend fun resetWellKnownConfig(): Result + + fun homeserverCapabilities(): HomeserverCapabilitiesProvider } /** diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProvider.kt new file mode 100644 index 0000000000..d82e389aa7 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider +import org.matrix.rustcomponents.sdk.HomeserverCapabilities + +class RustHomeserverCapabilitiesProvider( + private val homeserverCapabilities: HomeserverCapabilities, +) : HomeserverCapabilitiesProvider { + override suspend fun refresh(): Result = runCatchingExceptions { + homeserverCapabilities.refresh() + } + + override suspend fun canChangeDisplayName(): Result = runCatchingExceptions { + homeserverCapabilities.canChangeDisplayname() + } + + override suspend fun canChangeAvatarUrl(): Result = runCatchingExceptions { + homeserverCapabilities.canChangeAvatar() + } +} 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 1c87e73ba2..bb6806b5d4 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 @@ -17,6 +17,7 @@ import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.core.DeviceId @@ -835,6 +836,10 @@ class RustMatrixClient( val request = PerformDatabaseVacuumRequestBuilder(sessionId) sessionCoroutineScope.launch { workManagerScheduler.submit(request) } } + + override fun homeserverCapabilities(): HomeserverCapabilitiesProvider { + return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities()) + } } private fun defaultRoomCreationPowerLevels(isPublic: Boolean, isSpace: Boolean) = PowerLevels( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index 6ca7d27a8f..bef24003b9 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -13,6 +13,7 @@ import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.encryption.EncryptionService @@ -90,4 +91,9 @@ object SessionMatrixModule { fun providesSpaceService(matrixClient: MatrixClient): SpaceService { return matrixClient.spaceService } + + @Provides + fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider { + return matrixClient.homeserverCapabilities() + } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProviderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProviderTest.kt new file mode 100644 index 0000000000..8d3377d698 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustHomeserverCapabilitiesProviderTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverCapabilities +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RustHomeserverCapabilitiesProviderTest { + @Test + fun `refresh calls client refresh`() = runTest { + val refreshLambda = lambdaRecorder {} + val provider = createCapabilitiesProvider( + capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda), + ) + assertThat(provider.refresh().isSuccess).isTrue() + refreshLambda.assertions().isCalledOnce() + } + + @Test + fun `refresh fails when client refresh does`() = runTest { + val refreshLambda = lambdaRecorder { throw IllegalStateException("Failed to refresh capabilities") } + val provider = createCapabilitiesProvider( + capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda), + ) + assertThat(provider.refresh().isFailure).isTrue() + refreshLambda.assertions().isCalledOnce() + } + + @Test + fun `canChangeDisplayName returns expected value`() = runTest { + val provider = createCapabilitiesProvider( + capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { true }), + ) + assertThat(provider.canChangeDisplayName().getOrNull()).isTrue() + } + + @Test + fun `canChangeAvatarUrl returns expected value`() = runTest { + val provider = createCapabilitiesProvider( + capabilities = FakeFfiHomeserverCapabilities(canChangeAvatar = { true }), + ) + assertThat(provider.canChangeAvatarUrl().getOrNull()).isTrue() + } + + @Test + fun `canChangeDisplayName returns failure when client throws`() = runTest { + val provider = createCapabilitiesProvider( + capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { throw IllegalStateException("Failed to get display name capability") }), + ) + assert(provider.canChangeDisplayName().isFailure) + } + + private fun createCapabilitiesProvider( + capabilities: FakeFfiHomeserverCapabilities = FakeFfiHomeserverCapabilities(), + ) = RustHomeserverCapabilitiesProvider(capabilities) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index 2aec38fcde..57ffcddb37 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -17,6 +17,7 @@ import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientDelegate import org.matrix.rustcomponents.sdk.CreateRoomParameters import org.matrix.rustcomponents.sdk.Encryption +import org.matrix.rustcomponents.sdk.HomeserverCapabilities import org.matrix.rustcomponents.sdk.HomeserverLoginDetails import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.NoHandle @@ -50,6 +51,7 @@ class FakeFfiClient( private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() }, private val getStoreSizesResult: () -> StoreSizes = { lambdaError() }, private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() }, + private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(), private val closeResult: () -> Unit = {}, ) : Client(NoHandle) { override fun userId(): String = userId @@ -103,5 +105,9 @@ class FakeFfiClient( return createRoomResult(request) } + override fun homeserverCapabilities(): HomeserverCapabilities { + return homeserverCapabilities + } + override fun close() = closeResult() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverCapabilities.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverCapabilities.kt new file mode 100644 index 0000000000..4c60cbbb49 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverCapabilities.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import io.element.android.tests.testutils.lambda.lambdaError +import org.matrix.rustcomponents.sdk.ExtendedProfileFields +import org.matrix.rustcomponents.sdk.HomeserverCapabilities +import org.matrix.rustcomponents.sdk.NoHandle + +class FakeFfiHomeserverCapabilities( + private val refresh: () -> Unit = { lambdaError() }, + private val canChangeDisplayName: () -> Boolean = { lambdaError() }, + private val canChangeAvatar: () -> Boolean = { lambdaError() }, + private val canChangePassword: () -> Boolean = { lambdaError() }, + private val canChangeThirdPartyIds: () -> Boolean = { lambdaError() }, + private val canGetLoginToken: () -> Boolean = { lambdaError() }, + private val forgetsRoomWhenLeaving: () -> Boolean = { lambdaError() }, + private val extendedProfileFields: () -> ExtendedProfileFields = { lambdaError() }, +) : HomeserverCapabilities(NoHandle) { + override suspend fun refresh() = refresh.invoke() + override suspend fun canChangeDisplayname(): Boolean = canChangeDisplayName.invoke() + override suspend fun canChangeAvatar(): Boolean = canChangeAvatar.invoke() + override suspend fun canChangePassword(): Boolean = canChangePassword.invoke() + override suspend fun canChangeThirdpartyIds(): Boolean = canChangeThirdPartyIds.invoke() + override suspend fun canGetLoginToken(): Boolean = canGetLoginToken.invoke() + override suspend fun forgetsRoomWhenLeaving(): Boolean = forgetsRoomWhenLeaving.invoke() + override suspend fun extendedProfileFields(): ExtendedProfileFields = extendedProfileFields.invoke() +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeHomeserverCapabilitiesProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeHomeserverCapabilitiesProvider.kt new file mode 100644 index 0000000000..c098388c89 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeHomeserverCapabilitiesProvider.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test + +import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider + +class FakeHomeserverCapabilitiesProvider( + private val refresh: () -> Result = { Result.success(Unit) }, + private val canChangeDisplayName: () -> Result = { Result.success(true) }, + private val canChangeAvatarUrl: () -> Result = { Result.success(true) }, +) : HomeserverCapabilitiesProvider { + override suspend fun refresh(): Result = refresh.invoke() + override suspend fun canChangeDisplayName(): Result = canChangeDisplayName.invoke() + override suspend fun canChangeAvatarUrl(): Result = canChangeAvatarUrl.invoke() +} 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 56527574d7..742af160ae 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 @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.test +import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.core.DeviceId @@ -84,6 +85,7 @@ class FakeMatrixClient( override val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(), override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), + private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(), private val accountManagementUrlResult: (AccountManagementAction?) -> Result = { lambdaError() }, private val resolveRoomAliasResult: (RoomAlias) -> Result> = { Result.success( @@ -384,4 +386,8 @@ class FakeMatrixClient( override suspend fun resetWellKnownConfig(): Result { return resetWellKnownConfigLambda() } + + override fun homeserverCapabilities(): HomeserverCapabilitiesProvider { + return homeserverCapabilitiesProvider + } } diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt index 770543e548..50d5a5ce32 100644 --- a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt @@ -51,6 +51,7 @@ sealed interface SlashCommand { data class ChangeDisplayName(val displayName: String) : SlashCommandAdmin data class ChangeDisplayNameForRoom(val displayName: String) : SlashCommandAdmin data class ChangeRoomAvatar(val url: String) : SlashCommandAdmin + data class ChangeAvatar(val url: String) : SlashCommandAdmin data class ChangeAvatarForRoom(val url: String) : SlashCommandAdmin data class SendSpoiler(val message: String) : SlashCommandSendMessage data class SendWithPrefix(val prefix: MessagePrefix, val message: CharSequence) : SlashCommandSendMessage diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt index 0b7b58a15f..0d9e1e8c72 100644 --- a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt @@ -120,6 +120,15 @@ enum class Command( isDevCommand = true, isSupported = false, ), + CHANGE_AVATAR( + command = "/myavatar", + parameters = "", + description = R.string.slash_command_description_avatar, + isAllowedInThread = false, + // Dev command since user has to know the mxc url + isDevCommand = true, + isSupported = false, + ), CHANGE_AVATAR_FOR_ROOM( command = "/myroomavatar", parameters = "", diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt index 0acd3af6f8..ad252cb224 100644 --- a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt @@ -44,6 +44,7 @@ class CommandExecutor( ): Result { return when (slashCommand) { is SlashCommand.BanUser -> banUser(slashCommand) + is SlashCommand.ChangeAvatar -> changeAvatar() is SlashCommand.ChangeAvatarForRoom -> changeAvatarForRoom() is SlashCommand.ChangeDisplayName -> changeDisplayName(slashCommand) is SlashCommand.ChangeDisplayNameForRoom -> changeDisplayNameForRoom() @@ -178,6 +179,10 @@ class CommandExecutor( return matrixClient.setDisplayName(slashCommand.displayName) } + private fun changeAvatar(): Result { + return Result.failure(Exception("Not yet implemented")) + } + private fun changeAvatarForRoom(): Result { return Result.failure(Exception("Not yet implemented")) } diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt index 85a045f50c..55125af20b 100644 --- a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt @@ -107,6 +107,18 @@ class CommandParser( syntaxError(Command.ROOM_AVATAR) } } + Command.CHANGE_AVATAR.matches(slashCommand) -> { + if (messageParts.size == 2) { + val url = messageParts[1] + if (url.isMxcUrl()) { + SlashCommand.ChangeAvatar(url) + } else { + syntaxError(Command.CHANGE_AVATAR) + } + } else { + syntaxError(Command.CHANGE_AVATAR) + } + } Command.CHANGE_AVATAR_FOR_ROOM.matches(slashCommand) -> { if (messageParts.size == 2) { val url = messageParts[1] diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt index ba2786c944..6cd8688cad 100644 --- a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt @@ -11,6 +11,7 @@ import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.di.RoomScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.slashcommands.api.SlashCommand @@ -18,6 +19,8 @@ import io.element.android.libraries.slashcommands.api.SlashCommandService import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.seconds @ContributesBinding(RoomScope::class) class DefaultSlashCommandService( @@ -26,6 +29,7 @@ class DefaultSlashCommandService( private val stringProvider: StringProvider, private val appPreferencesStore: AppPreferencesStore, private val featureFlagService: FeatureFlagService, + private val capabilitiesProvider: HomeserverCapabilitiesProvider, ) : SlashCommandService { override suspend fun getSuggestions( text: String, @@ -33,19 +37,41 @@ class DefaultSlashCommandService( ): List { if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) return emptyList() val isDeveloperModeEnabled = appPreferencesStore.isDeveloperModeEnabledFlow().first() - return Command.entries.filter { - it.startsWith(text) - }.filter { - !isInThread || it.isAllowedInThread - }.filter { - !it.isDevCommand || isDeveloperModeEnabled - }.map { - SlashCommandSuggestion( - command = it.command, - parameters = it.parameters, - description = stringProvider.getString(it.description), - ) - } + return Command.entries + .asSequence() + .filter { it.startsWith(text) } + .filter { !isInThread || it.isAllowedInThread } + .filter { !it.isDevCommand || isDeveloperModeEnabled } + // Don't include the change display name commands if the user can't change their display name + .run { + val canUserChangeDisplayName = withTimeoutOrNull(5.seconds) { + capabilitiesProvider.canChangeDisplayName().getOrNull() + } ?: false + if (!canUserChangeDisplayName) { + filterNot { it == Command.CHANGE_DISPLAY_NAME || it == Command.CHANGE_DISPLAY_NAME_FOR_ROOM } + } else { + this + } + } + // Don't include the change avatar commands if the user can't change their avatar url + .run { + val canUserChangeAvatar = withTimeoutOrNull(5.seconds) { + capabilitiesProvider.canChangeAvatarUrl().getOrNull() + } ?: false + if (!canUserChangeAvatar) { + filterNot { it == Command.CHANGE_AVATAR || it == Command.CHANGE_AVATAR_FOR_ROOM } + } else { + this + } + } + .map { + SlashCommandSuggestion( + command = it.command, + parameters = it.parameters, + description = stringProvider.getString(it.description), + ) + } + .toList() } override suspend fun parse( diff --git a/libraries/slashcommands/impl/src/main/res/values/temporary.xml b/libraries/slashcommands/impl/src/main/res/values/temporary.xml index 0a8f2a0034..26232ea9b3 100644 --- a/libraries/slashcommands/impl/src/main/res/values/temporary.xml +++ b/libraries/slashcommands/impl/src/main/res/values/temporary.xml @@ -26,6 +26,7 @@ Set the room topic Removes user with given id from this room Changes your display nickname + Changes your profile picture in all rooms Sends the given message with confetti Sends the given message with snowfall Sends a message as plain text, without interpreting it as markdown diff --git a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt index 0887847a40..f5a6f54dfd 100644 --- a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt +++ b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt @@ -78,12 +78,19 @@ class CommandParserTest { } @Test - fun parseSlashCommandPlainAndNick() = runTest { + fun parseSlashCommandPlain() = runTest { test("/plain hello", SlashCommand.SendPlainText("hello")) test("/plain", SlashCommand.ErrorSyntax("A string/plain, /plain ")) + } + @Test + fun parseSlashCommandNickAndMyAvatar() = runTest { test("/nick John", SlashCommand.ChangeDisplayName("John")) test("/nick", SlashCommand.ErrorSyntax("A string/nick, /nick ")) + + test("/myavatar mxc://matrix.org/abc", SlashCommand.ChangeAvatar("mxc://matrix.org/abc")) + test("/myavatar http://notmxc", SlashCommand.ErrorSyntax("A string/myavatar, /myavatar ")) + test("/myavatar", SlashCommand.ErrorSyntax("A string/myavatar, /myavatar ")) } @Test diff --git a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt index 243f25666c..cee4d17b21 100644 --- a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt +++ b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt @@ -13,6 +13,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.timeline.MsgType +import io.element.android.libraries.matrix.test.FakeHomeserverCapabilitiesProvider 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.FakeJoinedRoom @@ -116,6 +117,26 @@ class DefaultSlashCommandServiceTest { sendMessage.assertions().isCalledOnce() } + @Test + fun `canChangeDisplayName is respected in suggestions`() = runTest { + var result = false + val capabilitiesProvider = FakeHomeserverCapabilitiesProvider( + canChangeDisplayName = { Result.success(result) }, + ) + val sut = createDefaultSlashCommandService(capabilitiesProvider = capabilitiesProvider) + + // Initially, with a disabled capability, the change display name command should not be in the suggestions + var changeNameCommand = sut.getSuggestions("", isInThread = false) + .find { it.command == Command.CHANGE_DISPLAY_NAME.command } + assertThat(changeNameCommand).isNull() + + // When the capability is true, the command should be included in the suggestions + result = true + changeNameCommand = sut.getSuggestions("", isInThread = false) + .find { it.command == Command.CHANGE_DISPLAY_NAME.command } + assertThat(changeNameCommand).isNotNull() + } + @Test fun `proceedAdmin delegates to commandExecutor`() = runTest { val leaveRoomLambda = lambdaRecorder> { @@ -155,11 +176,13 @@ class DefaultSlashCommandServiceTest { commandExecutor: CommandExecutor = createCommandExecutor( stringProvider = stringProvider, ), + capabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(), ) = DefaultSlashCommandService( commandParser = commandParser, commandExecutor = commandExecutor, stringProvider = stringProvider, appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService, + capabilitiesProvider = capabilitiesProvider, ) } diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Day_3_en.png new file mode 100644 index 0000000000..8517440a90 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4dbcac95e6fd72ee2eb683a4f25d49f4d181be9b1386ec01670fb078bc46a52 +size 18966 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Night_3_en.png new file mode 100644 index 0000000000..75ce9869d4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:901c40d524de6de6fd70cbfcfc8b1d86cc896734254452535a07830587adafcf +size 18987