Hide Element Call entry point if Element Call service is not available. (#4783)

* Hide Element Call entry point if Element Call service is not available.

* No need to preview the case RoomCallState.Unavailable

* Hide start call action from user profile if Element Call is not available.

* Add mising `use` and cover the problem by a test.

* Update screenshots

* Update enterprise submodule ref.

* Ensure `enterpriseService.isElementCallAvailable()` is not called several times.
And fix unit tests on CI

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2025-05-27 16:31:05 +02:00
committed by GitHub
parent 37120c5ee5
commit c86154d120
17 changed files with 146 additions and 45 deletions

View File

@@ -16,6 +16,8 @@ interface EnterpriseService {
fun defaultHomeserverList(): List<String>
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
suspend fun isElementCallAvailable(): Boolean
fun semanticColorsLight(): SemanticColors
fun semanticColorsDark(): SemanticColors

View File

@@ -25,6 +25,8 @@ class DefaultEnterpriseService @Inject constructor() : EnterpriseService {
override fun defaultHomeserverList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
override suspend fun isElementCallAvailable(): Boolean = true
override fun semanticColorsLight(): SemanticColors = compoundColorsLight
override fun semanticColorsDark(): SemanticColors = compoundColorsDark

View File

@@ -18,6 +18,7 @@ class FakeEnterpriseService(
private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() },
private val defaultHomeserverListResult: () -> List<String> = { emptyList() },
private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() },
private val isElementCallAvailableResult: () -> Boolean = { lambdaError() },
private val semanticColorsLightResult: () -> SemanticColors = { lambdaError() },
private val semanticColorsDarkResult: () -> SemanticColors = { lambdaError() },
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
@@ -35,6 +36,10 @@ class FakeEnterpriseService(
isAllowedToConnectToHomeserverResult(homeserverUrl)
}
override suspend fun isElementCallAvailable(): Boolean = simulateLongTask {
isElementCallAvailableResult()
}
override fun semanticColorsLight(): SemanticColors {
return semanticColorsLightResult()
}

View File

@@ -38,6 +38,9 @@ internal fun CallMenuItem(
modifier: Modifier = Modifier,
) {
when (roomCallState) {
RoomCallState.Unavailable -> {
Box(modifier)
}
is RoomCallState.StandBy -> {
StandByCallMenuItem(
roomCallState = roomCallState,

View File

@@ -103,10 +103,12 @@ internal fun TimelineItemCallNotifyView(
@PreviewsDayNight
@Composable
internal fun TimelineItemCallNotifyViewPreview() {
ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
RoomCallStateProvider().values.forEach { roomCallState ->
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
RoomCallStateProvider()
.values
.filter { it !is RoomCallState.Unavailable }
.forEach { roomCallState ->
TimelineItemCallNotifyView(
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
roomCallState = roomCallState,
@@ -114,6 +116,5 @@ internal fun TimelineItemCallNotifyViewPreview() {
onJoinCallClick = {},
)
}
}
}
}

View File

@@ -13,6 +13,8 @@ import io.element.android.features.roomcall.api.RoomCallState.StandBy
@Immutable
sealed interface RoomCallState {
data object Unavailable : RoomCallState
data class StandBy(
val canStartCall: Boolean,
) : RoomCallState
@@ -25,6 +27,7 @@ sealed interface RoomCallState {
}
fun RoomCallState.hasPermissionToJoin() = when (this) {
RoomCallState.Unavailable -> false
is StandBy -> canStartCall
is OnGoing -> canJoinCall
}

View File

@@ -16,6 +16,7 @@ open class RoomCallStateProvider : PreviewParameterProvider<RoomCallState> {
anOngoingCallState(),
anOngoingCallState(canJoinCall = false),
anOngoingCallState(canJoinCall = true, isUserInTheCall = true),
RoomCallState.Unavailable,
)
}

View File

@@ -21,6 +21,7 @@ dependencies {
api(projects.features.roomcall.api)
implementation(libs.kotlinx.collections.immutable)
implementation(projects.features.call.api)
implementation(projects.features.enterprise.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
@@ -32,6 +33,7 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.call.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)

View File

@@ -11,9 +11,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -23,9 +25,13 @@ import javax.inject.Inject
class RoomCallStatePresenter @Inject constructor(
private val room: JoinedRoom,
private val currentCallService: CurrentCallService,
private val enterpriseService: EnterpriseService,
) : Presenter<RoomCallState> {
@Composable
override fun present(): RoomCallState {
val isAvailable by produceState(false) {
value = enterpriseService.isElementCallAvailable()
}
val roomInfo by room.roomInfoFlow.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
@@ -41,6 +47,7 @@ class RoomCallStatePresenter @Inject constructor(
}
}
val callState = when {
isAvailable.not() -> RoomCallState.Unavailable
roomInfo.hasRoomCall -> RoomCallState.OnGoing(
canJoinCall = canJoinCall,
isUserInTheCall = isUserInTheCall,

View File

@@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService
import io.element.android.features.call.test.FakeCurrentCallService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
@@ -25,12 +26,13 @@ class RoomCallStatePresenterTest {
@Test
fun `present - initial state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(false) },
)
)
val presenter = createRoomCallStatePresenter(joinedRoom = room)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
RoomCallState.StandBy(
@@ -40,10 +42,29 @@ class RoomCallStatePresenterTest {
}
}
@Test
fun `present - element call not available`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(false) },
)
)
val presenter = createRoomCallStatePresenter(
joinedRoom = room,
isElementCallAvailable = false,
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
RoomCallState.Unavailable
)
}
}
@Test
fun `present - initial state - user can join call`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) },
)
)
@@ -69,6 +90,7 @@ class RoomCallStatePresenterTest {
)
val presenter = createRoomCallStatePresenter(joinedRoom = room)
presenter.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(
RoomCallState.OnGoing(
canJoinCall = false,
@@ -83,15 +105,15 @@ class RoomCallStatePresenterTest {
fun `present - user has joined the call on another session`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) },
).apply {
givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeRoomCallParticipants = listOf(sessionId),
canUserJoinCallResult = { Result.success(true) },
).apply {
givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeRoomCallParticipants = listOf(sessionId),
)
)
)
}
}
)
val presenter = createRoomCallStatePresenter(joinedRoom = room)
presenter.test {
@@ -110,15 +132,15 @@ class RoomCallStatePresenterTest {
fun `present - user has joined the call locally`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) },
).apply {
givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeRoomCallParticipants = listOf(sessionId),
canUserJoinCallResult = { Result.success(true) },
).apply {
givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeRoomCallParticipants = listOf(sessionId),
)
)
)
}
}
)
val presenter = createRoomCallStatePresenter(
joinedRoom = room,
@@ -140,15 +162,15 @@ class RoomCallStatePresenterTest {
fun `present - user leaves the call`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) },
).apply {
givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeRoomCallParticipants = listOf(sessionId),
canUserJoinCallResult = { Result.success(true) },
).apply {
givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeRoomCallParticipants = listOf(sessionId),
)
)
)
}
}
)
val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.RoomCall(room.roomId))
val currentCallService = FakeCurrentCallService(currentCall = currentCall)
@@ -203,10 +225,14 @@ class RoomCallStatePresenterTest {
private fun createRoomCallStatePresenter(
joinedRoom: JoinedRoom,
currentCallService: CurrentCallService = FakeCurrentCallService(),
isElementCallAvailable: Boolean = true,
): RoomCallStatePresenter {
return RoomCallStatePresenter(
room = joinedRoom,
currentCallService = currentCallService,
enterpriseService = FakeEnterpriseService(
isElementCallAvailableResult = { isElementCallAvailable },
),
)
}
}

View File

@@ -33,6 +33,7 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.features.call.api)
implementation(projects.features.enterprise.api)
implementation(projects.features.verifysession.api)
api(projects.features.userprofile.api)
api(projects.features.userprofile.shared)
@@ -49,6 +50,7 @@ dependencies {
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.createroom.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)

View File

@@ -21,6 +21,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileState.ConfirmationDialog
@@ -44,6 +45,7 @@ class UserProfilePresenter @AssistedInject constructor(
@Assisted private val userId: UserId,
private val client: MatrixClient,
private val startDMAction: StartDMAction,
private val enterpriseService: EnterpriseService,
) : Presenter<UserProfileState> {
@AssistedFactory
interface Factory {
@@ -59,11 +61,21 @@ class UserProfilePresenter @AssistedInject constructor(
@Composable
private fun getCanCall(roomId: RoomId?): State<Boolean> {
return produceState(initialValue = false, roomId) {
value = if (client.isMe(userId)) {
false
} else {
roomId?.let { client.getRoom(it)?.canUserJoinCall(client.sessionId)?.getOrNull() == true }.orFalse()
val isElementCallAvailable by produceState(initialValue = false, roomId) {
value = enterpriseService.isElementCallAvailable()
}
return produceState(initialValue = false, isElementCallAvailable, roomId) {
value = when {
isElementCallAvailable.not() -> false
client.isMe(userId) -> false
else ->
roomId
?.let { client.getRoom(it) }
?.use { room ->
room.canUserJoinCall(client.sessionId).getOrNull()
}
.orFalse()
}
}
}

View File

@@ -16,6 +16,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
@@ -37,7 +38,6 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -81,6 +81,8 @@ class UserProfilePresenterTest {
fun `present - canCall is true when all the conditions are met`() {
testCanCall(
expectedResult = true,
skipItems = 3,
checkThatRoomIsDestroyed = true,
)
}
@@ -116,11 +118,22 @@ class UserProfilePresenterTest {
)
}
@Test
fun `present - canCall is false when call is not available`() {
testCanCall(
isElementCallAvailable = false,
expectedResult = false,
)
}
private fun testCanCall(
isElementCallAvailable: Boolean = true,
canUserJoinCallResult: Result<Boolean> = Result.success(true),
dmRoom: RoomId? = A_ROOM_ID,
canFindRoom: Boolean = true,
expectedResult: Boolean,
skipItems: Int = 1,
checkThatRoomIsDestroyed: Boolean = false,
) = runTest {
val room = FakeBaseRoom(
canUserJoinCallResult = { canUserJoinCallResult },
@@ -134,11 +147,15 @@ class UserProfilePresenterTest {
val presenter = createUserProfilePresenter(
userId = A_USER_ID_2,
client = client,
isElementCallAvailable = isElementCallAvailable,
)
presenter.test {
val initialState = awaitLastSequentialItem()
val initialState = awaitFirstItem(skipItems)
assertThat(initialState.canCall).isEqualTo(expectedResult)
}
if (checkThatRoomIsDestroyed) {
room.assertDestroyed()
}
}
@Test
@@ -202,7 +219,7 @@ class UserProfilePresenterTest {
)
val presenter = createUserProfilePresenter(client = matrixClient)
presenter.test {
val initialState = awaitFirstItem()
val initialState = awaitFirstItem(count = 2)
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
val errorState = awaitItem()
@@ -220,7 +237,7 @@ class UserProfilePresenterTest {
)
val presenter = createUserProfilePresenter(client = matrixClient)
presenter.test {
val initialState = awaitFirstItem()
val initialState = awaitFirstItem(count = 2)
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
val errorState = awaitItem()
@@ -363,8 +380,8 @@ class UserProfilePresenterTest {
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(count: Int = 1): T {
skipItems(count)
return awaitItem()
}
@@ -387,12 +404,16 @@ class UserProfilePresenterTest {
private fun createUserProfilePresenter(
client: MatrixClient = createFakeMatrixClient(),
userId: UserId = UserId("@alice:server.org"),
startDMAction: StartDMAction = FakeStartDMAction()
startDMAction: StartDMAction = FakeStartDMAction(),
isElementCallAvailable: Boolean = true,
): UserProfilePresenter {
return UserProfilePresenter(
userId = userId,
client = client,
startDMAction = startDMAction,
enterpriseService = FakeEnterpriseService(
isElementCallAvailableResult = { isElementCallAvailable },
),
)
}
}

View File

@@ -93,7 +93,15 @@ class FakeBaseRoom(
return powerLevelsResult()
}
override fun destroy() = Unit
private var isDestroyed = false
override fun destroy() {
isDestroyed = true
}
fun assertDestroyed() {
check(isDestroyed) { "Room should be destroyed" }
}
override suspend fun userDisplayName(userId: UserId): Result<String?> = simulateLongTask {
userDisplayNameResult(userId)