Merge branch 'develop' into feature/fga/fix_left_room_membership_change

This commit is contained in:
ganfra 2025-05-27 22:08:17 +02:00
commit b25f9571f2
153 changed files with 2885 additions and 1088 deletions

View file

@ -11,7 +11,7 @@ env:
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
ARCH: x86_64 ARCH: x86_64
DEVICE: pixel_7_pro DEVICE: pixel_7_pro
API_LEVEL: 35 API_LEVEL: 33
TARGET: google_apis TARGET: google_apis
jobs: jobs:

View file

@ -1,4 +1,5 @@
appId: ${MAESTRO_APP_ID} appId: ${MAESTRO_APP_ID}
androidWebViewHierarchy: devtools
--- ---
## Check that all env variables required in the whole test suite are declared (to fail faster) ## Check that all env variables required in the whole test suite are declared (to fail faster)
- runScript: ./scripts/checkEnv.js - runScript: ./scripts/checkEnv.js

View file

@ -14,7 +14,22 @@ appId: ${MAESTRO_APP_ID}
visible: 'Use without an account' visible: 'Use without an account'
commands: commands:
- tapOn: "Use without an account" - tapOn: "Use without an account"
## For older chrome versions
- runFlow:
when:
visible: 'Accept & continue'
commands:
- tapOn: "Accept & continue"
- runFlow:
when:
visible: 'No thanks'
commands:
- tapOn: "No thanks"
## Working when running Maestro locally, but not on the CI yet. ## Working when running Maestro locally, but not on the CI yet.
- extendedWaitUntil:
visible:
id: "form-1"
timeout: 10000
- tapOn: - tapOn:
id: "form-1" id: "form-1"
- inputText: ${MAESTRO_USERNAME} - inputText: ${MAESTRO_USERNAME}

View file

@ -354,3 +354,12 @@ fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) {
} }
} }
} }
configurations.all {
resolutionStrategy {
dependencySubstitution {
val tink = libs.google.tink.get()
substitute(module("com.google.crypto.tink:tink")).using(module("${tink.group}:${tink.name}:${tink.version}"))
}
}
}

View file

@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -51,6 +52,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val activeRoomsHolder: ActiveRoomsHolder,
roomComponentFactory: RoomComponentFactory, roomComponentFactory: RoomComponentFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>( ) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -85,6 +87,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
onCreate = { onCreate = {
Timber.v("OnCreate => ${inputs.room.roomId}") Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
activeRoomsHolder.addRoom(inputs.room)
fetchRoomMembers() fetchRoomMembers()
trackVisitedRoom() trackVisitedRoom()
}, },
@ -95,6 +98,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
}, },
onDestroy = { onDestroy = {
Timber.v("OnDestroy") Timber.v("OnDestroy")
activeRoomsHolder.removeRoom(inputs.room.sessionId, inputs.room.roomId)
inputs.room.destroy() inputs.room.destroy()
appNavigationStateService.onLeavingRoom(id) appNavigationStateService.onLeavingRoom(id)
} }

View file

@ -24,16 +24,18 @@ import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.childNode import io.element.android.libraries.architecture.childNode
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient 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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
class JoinBaseRoomLoadedFlowNodeTest { class JoinedRoomLoadedFlowNodeTest {
@get:Rule @get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule() val instantTaskExecutorRule = InstantTaskExecutorRule()
@ -96,20 +98,21 @@ class JoinBaseRoomLoadedFlowNodeTest {
} }
} }
private fun createJoinedRoomLoadedFlowNode( private fun TestScope.createJoinedRoomLoadedFlowNode(
plugins: List<Plugin>, plugins: List<Plugin>,
messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(), messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(), roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
coroutineScope: CoroutineScope, activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
) = JoinedRoomLoadedFlowNode( ) = JoinedRoomLoadedFlowNode(
buildContext = BuildContext.root(savedStateMap = null), buildContext = BuildContext.root(savedStateMap = null),
plugins = plugins, plugins = plugins,
messagesEntryPoint = messagesEntryPoint, messagesEntryPoint = messagesEntryPoint,
roomDetailsEntryPoint = roomDetailsEntryPoint, roomDetailsEntryPoint = roomDetailsEntryPoint,
appNavigationStateService = FakeAppNavigationStateService(), appNavigationStateService = FakeAppNavigationStateService(),
appCoroutineScope = coroutineScope, appCoroutineScope = this,
roomComponentFactory = FakeRoomComponentFactory(), roomComponentFactory = FakeRoomComponentFactory(),
matrixClient = FakeMatrixClient(), matrixClient = FakeMatrixClient(),
activeRoomsHolder = activeRoomsHolder,
) )
@Test @Test
@ -121,7 +124,6 @@ class JoinBaseRoomLoadedFlowNodeTest {
val roomFlowNode = createJoinedRoomLoadedFlowNode( val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs), plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint, messagesEntryPoint = fakeMessagesEntryPoint,
coroutineScope = this
) )
// WHEN // WHEN
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
@ -144,7 +146,6 @@ class JoinBaseRoomLoadedFlowNodeTest {
plugins = listOf(inputs), plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint, messagesEntryPoint = fakeMessagesEntryPoint,
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint, roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
coroutineScope = this
) )
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// WHEN // WHEN
@ -154,4 +155,53 @@ class JoinBaseRoomLoadedFlowNodeTest {
val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!! val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!!
assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId) assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId)
} }
@Test
fun `the ActiveRoomsHolder will be updated with the loaded room on create`() = runTest {
// GIVEN
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
val activeRoomsHolder = ActiveRoomsHolder()
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint,
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
activeRoomsHolder = activeRoomsHolder,
)
assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull()
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// WHEN
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.CREATED)
// THEN
assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNotNull()
}
@Test
fun `the ActiveRoomsHolder will be removed on destroy`() = runTest {
// GIVEN
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
val activeRoomsHolder = ActiveRoomsHolder().apply {
addRoom(room)
}
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint,
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
activeRoomsHolder = activeRoomsHolder,
)
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.CREATED)
assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNotNull()
// WHEN
roomFlowNode.updateLifecycleState(Lifecycle.State.DESTROYED)
// THEN
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.DESTROYED)
assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull()
}
} }

@ -1 +1 @@
Subproject commit d3dffc97bf8b39386ace2db7d857bbff05c73c18 Subproject commit 4a07c862a23a9fd1418eabf132cf9d6b25ea4927

View file

@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker import io.element.android.services.analytics.api.ScreenTracker
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppForegroundStateService import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -62,6 +63,7 @@ class CallScreenPresenter @AssistedInject constructor(
private val activeCallManager: ActiveCallManager, private val activeCallManager: ActiveCallManager,
private val languageTagProvider: LanguageTagProvider, private val languageTagProvider: LanguageTagProvider,
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
private val activeRoomsHolder: ActiveRoomsHolder,
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
) : Presenter<CallScreenState> { ) : Presenter<CallScreenState> {
@AssistedFactory @AssistedFactory
@ -241,8 +243,10 @@ class CallScreenPresenter @AssistedInject constructor(
private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) { private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) {
if (!notifiedCallStart) { if (!notifiedCallStart) {
getJoinedRoom(roomId)?.use { it.sendCallNotificationIfNeeded() } val activeRoomForSession = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
?.onSuccess { notifiedCallStart = true } val sendCallNotificationResult = activeRoomForSession?.sendCallNotificationIfNeeded()
?: getJoinedRoom(roomId)?.use { it.sendCallNotificationIfNeeded() }
sendCallNotificationResult?.onSuccess { notifiedCallStart = true }
} }
} }

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject import javax.inject.Inject
@ -24,6 +25,7 @@ class DefaultCallWidgetProvider @Inject constructor(
private val matrixClientsProvider: MatrixClientProvider, private val matrixClientsProvider: MatrixClientProvider,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val callWidgetSettingsProvider: CallWidgetSettingsProvider, private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
private val activeRoomsHolder: ActiveRoomsHolder,
) : CallWidgetProvider { ) : CallWidgetProvider {
override suspend fun getWidget( override suspend fun getWidget(
sessionId: SessionId, sessionId: SessionId,
@ -33,7 +35,9 @@ class DefaultCallWidgetProvider @Inject constructor(
theme: String?, theme: String?,
): Result<CallWidgetProvider.GetWidgetResult> = runCatching { ): Result<CallWidgetProvider.GetWidgetResult> = runCatching {
val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow() val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow()
val room = matrixClient.getJoinedRoom(roomId) ?: error("Room not found") val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
?: matrixClient.getJoinedRoom(roomId)
?: error("Room not found")
val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL

View file

@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker import io.element.android.services.analytics.api.ScreenTracker
import io.element.android.services.analytics.test.FakeScreenTracker import io.element.android.services.analytics.test.FakeScreenTracker
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -367,6 +368,7 @@ import kotlin.time.Duration.Companion.seconds
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
screenTracker: ScreenTracker = FakeScreenTracker(), screenTracker: ScreenTracker = FakeScreenTracker(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(), appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
): CallScreenPresenter { ): CallScreenPresenter {
val userAgentProvider = object : UserAgentProvider { val userAgentProvider = object : UserAgentProvider {
override fun provide(): String { override fun provide(): String {
@ -387,6 +389,7 @@ import kotlin.time.Duration.Companion.seconds
languageTagProvider = FakeLanguageTagProvider("en-US"), languageTagProvider = FakeLanguageTagProvider("en-US"),
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
appCoroutineScope = backgroundScope, appCoroutineScope = backgroundScope,
activeRoomsHolder = activeRoomsHolder,
) )
} }
} }

View file

@ -40,11 +40,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
@ -63,30 +59,28 @@ class DefaultActiveCallManagerTest {
fun `registerIncomingCall - sets the incoming call as active`() = runTest { fun `registerIncomingCall - sets the incoming call as active`() = runTest {
setupShadowPowerManager() setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
inCancellableScope { val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeWakeLock?.isHeld).isFalse() assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
val callNotificationData = aCallNotificationData() val callNotificationData = aCallNotificationData()
manager.registerIncomingCall(callNotificationData) manager.registerIncomingCall(callNotificationData)
assertThat(manager.activeCall.value).isEqualTo( assertThat(manager.activeCall.value).isEqualTo(
ActiveCall( ActiveCall(
callType = CallType.RoomCall( callType = CallType.RoomCall(
sessionId = callNotificationData.sessionId, sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId, roomId = callNotificationData.roomId,
), ),
callState = CallState.Ringing(callNotificationData) callState = CallState.Ringing(callNotificationData)
)
) )
)
runCurrent() runCurrent()
assertThat(manager.activeWakeLock?.isHeld).isTrue() assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify { notificationManagerCompat.notify(notificationId, any()) } verify { notificationManagerCompat.notify(notificationId, any()) }
}
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@ -94,42 +88,38 @@ class DefaultActiveCallManagerTest {
fun `registerIncomingCall - when there is an already active call adds missed call notification`() = runTest { fun `registerIncomingCall - when there is an already active call adds missed call notification`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> } val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
val onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda) val onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
inCancellableScope { val manager = createActiveCallManager(
val manager = createActiveCallManager( onMissedCallNotificationHandler = onMissedCallNotificationHandler,
onMissedCallNotificationHandler = onMissedCallNotificationHandler, )
)
// Register existing call // Register existing call
val callNotificationData = aCallNotificationData() val callNotificationData = aCallNotificationData()
manager.registerIncomingCall(callNotificationData) manager.registerIncomingCall(callNotificationData)
val activeCall = manager.activeCall.value val activeCall = manager.activeCall.value
// Now add a new call // Now add a new call
manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2)) manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
assertThat(manager.activeCall.value).isEqualTo(activeCall) assertThat(manager.activeCall.value).isEqualTo(activeCall)
assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2) assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2)
advanceTimeBy(1) advanceTimeBy(1)
addMissedCallNotificationLambda.assertions() addMissedCallNotificationLambda.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID)) .with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID))
}
} }
@Test @Test
fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest { fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> } val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
inCancellableScope { val manager = createActiveCallManager(
val manager = createActiveCallManager( onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda) )
)
manager.incomingCallTimedOut(displayMissedCallNotification = true) manager.incomingCallTimedOut(displayMissedCallNotification = true)
addMissedCallNotificationLambda.assertions().isNeverCalled() addMissedCallNotificationLambda.assertions().isNeverCalled()
}
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@ -138,90 +128,80 @@ class DefaultActiveCallManagerTest {
setupShadowPowerManager() setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> } val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
inCancellableScope { val manager = createActiveCallManager(
val manager = createActiveCallManager( onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda), notificationManagerCompat = notificationManagerCompat,
notificationManagerCompat = notificationManagerCompat, )
)
manager.registerIncomingCall(aCallNotificationData()) manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue() assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.incomingCallTimedOut(displayMissedCallNotification = true) manager.incomingCallTimedOut(displayMissedCallNotification = true)
advanceTimeBy(1) advanceTimeBy(1)
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse() assertThat(manager.activeWakeLock?.isHeld).isFalse()
addMissedCallNotificationLambda.assertions().isCalledOnce() addMissedCallNotificationLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) } verify { notificationManagerCompat.cancel(notificationId) }
}
} }
@Test @Test
fun `hungUpCall - removes existing call if the CallType matches`() = runTest { fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
setupShadowPowerManager() setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
val notificationData = aCallNotificationData() val notificationData = aCallNotificationData()
manager.registerIncomingCall(notificationData) manager.registerIncomingCall(notificationData)
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue() assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse() assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify { notificationManagerCompat.cancel(notificationId) } verify { notificationManagerCompat.cancel(notificationId) }
}
} }
@Test @Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest { fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
setupShadowPowerManager() setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
manager.registerIncomingCall(aCallNotificationData()) manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue() assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.ExternalUrl("https://example.com")) manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue() assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) } verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
}
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `joinedCall - register an ongoing call and tries sending the call notify event`() = runTest { fun `joinedCall - register an ongoing call and tries sending the call notify event`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
inCancellableScope { val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeCall.value).isNull()
manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID)) manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
assertThat(manager.activeCall.value).isEqualTo( assertThat(manager.activeCall.value).isEqualTo(
ActiveCall( ActiveCall(
callType = CallType.RoomCall( callType = CallType.RoomCall(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
), ),
callState = CallState.InCall, callState = CallState.InCall,
)
) )
)
runCurrent() runCurrent()
verify { notificationManagerCompat.cancel(notificationId) } verify { notificationManagerCompat.cancel(notificationId) }
}
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@ -233,22 +213,19 @@ class DefaultActiveCallManagerTest {
val client = FakeMatrixClient().apply { val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room) givenGetRoomResult(A_ROOM_ID, room)
} }
// Create a cancellable coroutine scope to cancel the test when needed val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
inCancellableScope { val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
manager.registerIncomingCall(aCallNotificationData()) manager.registerIncomingCall(aCallNotificationData())
// Call is active (the other user join the call) // Call is active (the other user join the call)
room.givenRoomInfo(aRoomInfo(hasRoomCall = true)) room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1) advanceTimeBy(1)
// Call is cancelled (the other user left the call) // Call is cancelled (the other user left the call)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false)) room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1) advanceTimeBy(1)
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
}
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@ -260,44 +237,34 @@ class DefaultActiveCallManagerTest {
val client = FakeMatrixClient().apply { val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room) givenGetRoomResult(A_ROOM_ID, room)
} }
// Create a cancellable coroutine scope to cancel the test when needed val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("Matrix client not found")) })
inCancellableScope { val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("Matrix client not found")) })
val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
// No matrix client // No matrix client
manager.registerIncomingCall(aCallNotificationData()) manager.registerIncomingCall(aCallNotificationData())
room.givenRoomInfo(aRoomInfo(hasRoomCall = true)) room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1) advanceTimeBy(1)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false)) room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1) advanceTimeBy(1)
// The call should still be active // The call should still be active
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
// No room // No room
client.givenGetRoomResult(A_ROOM_ID, null) client.givenGetRoomResult(A_ROOM_ID, null)
matrixClientProvider.getClient = { Result.success(client) } matrixClientProvider.getClient = { Result.success(client) }
manager.registerIncomingCall(aCallNotificationData()) manager.registerIncomingCall(aCallNotificationData())
room.givenRoomInfo(aRoomInfo(hasRoomCall = true)) room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1) advanceTimeBy(1)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false)) room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1) advanceTimeBy(1)
// The call should still be active // The call should still be active
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
}
}
private fun TestScope.inCancellableScope(block: suspend CoroutineScope.() -> Unit) {
launch(SupervisorJob()) {
block()
cancel()
}
} }
private fun setupShadowPowerManager() { private fun setupShadowPowerManager() {
@ -306,14 +273,13 @@ class DefaultActiveCallManagerTest {
} }
} }
private fun CoroutineScope.createActiveCallManager( private fun TestScope.createActiveCallManager(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(), onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true), notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
coroutineScope: CoroutineScope = this,
) = DefaultActiveCallManager( ) = DefaultActiveCallManager(
context = InstrumentationRegistry.getInstrumentation().targetContext, context = InstrumentationRegistry.getInstrumentation().targetContext,
coroutineScope = coroutineScope, coroutineScope = backgroundScope,
onMissedCallNotificationHandler = onMissedCallNotificationHandler, onMissedCallNotificationHandler = onMissedCallNotificationHandler,
ringingCallNotificationCreator = RingingCallNotificationCreator( ringingCallNotificationCreator = RingingCallNotificationCreator(
context = InstrumentationRegistry.getInstrumentation().targetContext, context = InstrumentationRegistry.getInstrumentation().targetContext,

View file

@ -15,11 +15,13 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -77,6 +79,29 @@ class DefaultCallWidgetProviderTest {
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").getOrNull()).isNotNull() assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").getOrNull()).isNotNull()
} }
@Test
fun `getWidget - reuses the active room if possible`() = runTest {
val client = FakeMatrixClient().apply {
// No room from the client
givenGetRoomResult(A_ROOM_ID, null)
}
val activeRoomsHolder = ActiveRoomsHolder().apply {
// A current active room with the same room id
addRoom(
FakeJoinedRoom(
baseRoom = FakeBaseRoom(roomId = A_ROOM_ID),
generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
)
)
}
val provider = createProvider(
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
activeRoomsHolder = activeRoomsHolder
)
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isSuccess).isTrue()
}
@Test @Test
fun `getWidget - will use a custom base url if it exists`() = runTest { fun `getWidget - will use a custom base url if it exists`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
@ -104,9 +129,11 @@ class DefaultCallWidgetProviderTest {
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(), callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
) = DefaultCallWidgetProvider( ) = DefaultCallWidgetProvider(
matrixClientsProvider = matrixClientProvider, matrixClientsProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
callWidgetSettingsProvider = callWidgetSettingsProvider, callWidgetSettingsProvider = callWidgetSettingsProvider,
activeRoomsHolder = activeRoomsHolder,
) )
} }

View file

@ -16,9 +16,21 @@ interface EnterpriseService {
fun defaultHomeserverList(): List<String> fun defaultHomeserverList(): List<String>
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
suspend fun isElementCallAvailable(): Boolean
fun semanticColorsLight(): SemanticColors fun semanticColorsLight(): SemanticColors
fun semanticColorsDark(): SemanticColors fun semanticColorsDark(): SemanticColors
fun firebasePushGateway(): String? fun firebasePushGateway(): String?
fun unifiedPushDefaultPushGateway(): String? fun unifiedPushDefaultPushGateway(): String?
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
}
fun EnterpriseService.canConnectToAnyHomeserver(): Boolean {
return defaultHomeserverList().let {
it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER)
}
} }

View file

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

View file

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

View file

@ -20,14 +20,14 @@ import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class LockScreenSettingsPresenterTest { class LockScreenSettingsPresenterTest {
@Test @Test
fun `present - remove pin option is hidden when mandatory`() = runTest { fun `present - remove pin option is hidden when mandatory`() = runTest {
val presenter = createLockScreenSettingsPresenter(this, lockScreenConfig = aLockScreenConfig(isPinMandatory = true)) val presenter = createLockScreenSettingsPresenter(lockScreenConfig = aLockScreenConfig(isPinMandatory = true))
presenter.test { presenter.test {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.showRemovePinOption).isFalse() assertThat(state.showRemovePinOption).isFalse()
@ -37,7 +37,7 @@ class LockScreenSettingsPresenterTest {
@Test @Test
fun `present - remove pin flow`() = runTest { fun `present - remove pin flow`() = runTest {
val presenter = createLockScreenSettingsPresenter(this) val presenter = createLockScreenSettingsPresenter()
presenter.test { presenter.test {
consumeItemsUntilPredicate { state -> consumeItemsUntilPredicate { state ->
state.showRemovePinOption state.showRemovePinOption
@ -71,7 +71,6 @@ class LockScreenSettingsPresenterTest {
isDeviceSecured = true, isDeviceSecured = true,
) )
val presenter = createLockScreenSettingsPresenter( val presenter = createLockScreenSettingsPresenter(
coroutineScope = this,
biometricAuthenticatorManager = fakeBiometricAuthenticatorManager biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
) )
presenter.test { presenter.test {
@ -88,7 +87,6 @@ class LockScreenSettingsPresenterTest {
} }
) )
val presenter = createLockScreenSettingsPresenter( val presenter = createLockScreenSettingsPresenter(
coroutineScope = this,
biometricAuthenticatorManager = fakeBiometricAuthenticatorManager biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
) )
presenter.test { presenter.test {
@ -110,7 +108,6 @@ class LockScreenSettingsPresenterTest {
} }
) )
val presenter = createLockScreenSettingsPresenter( val presenter = createLockScreenSettingsPresenter(
coroutineScope = this,
biometricAuthenticatorManager = fakeBiometricAuthenticatorManager biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
) )
presenter.test { presenter.test {
@ -130,7 +127,6 @@ class LockScreenSettingsPresenterTest {
) )
val lockScreenStore = InMemoryLockScreenStore() val lockScreenStore = InMemoryLockScreenStore()
val presenter = createLockScreenSettingsPresenter( val presenter = createLockScreenSettingsPresenter(
coroutineScope = this,
lockScreenStore = lockScreenStore, lockScreenStore = lockScreenStore,
biometricAuthenticatorManager = fakeBiometricAuthenticatorManager biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
) )
@ -148,8 +144,7 @@ class LockScreenSettingsPresenterTest {
} }
} }
private suspend fun createLockScreenSettingsPresenter( private suspend fun TestScope.createLockScreenSettingsPresenter(
coroutineScope: CoroutineScope,
lockScreenConfig: LockScreenConfig = aLockScreenConfig(), lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(), biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
lockScreenStore: LockScreenStore = InMemoryLockScreenStore(), lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
@ -160,7 +155,7 @@ class LockScreenSettingsPresenterTest {
return LockScreenSettingsPresenter( return LockScreenSettingsPresenter(
lockScreenStore = lockScreenStore, lockScreenStore = lockScreenStore,
pinCodeManager = pinCodeManager, pinCodeManager = pinCodeManager,
coroutineScope = coroutineScope, coroutineScope = this,
lockScreenConfig = lockScreenConfig, lockScreenConfig = lockScreenConfig,
biometricAuthenticatorManager = biometricAuthenticatorManager, biometricAuthenticatorManager = biometricAuthenticatorManager,
) )

View file

@ -24,7 +24,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -34,7 +34,7 @@ class PinUnlockPresenterTest {
@Test @Test
fun `present - success verify flow`() = runTest { fun `present - success verify flow`() = runTest {
val presenter = createPinUnlockPresenter(this) val presenter = createPinUnlockPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -71,7 +71,7 @@ class PinUnlockPresenterTest {
@Test @Test
fun `present - failure verify flow`() = runTest { fun `present - failure verify flow`() = runTest {
val presenter = createPinUnlockPresenter(this) val presenter = createPinUnlockPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -100,7 +100,7 @@ class PinUnlockPresenterTest {
fun `present - forgot pin flow`() = runTest { fun `present - forgot pin flow`() = runTest {
val signOutLambda = lambdaRecorder<Boolean, Unit> {} val signOutLambda = lambdaRecorder<Boolean, Unit> {}
val signOut = FakeLogoutUseCase(signOutLambda) val signOut = FakeLogoutUseCase(signOutLambda)
val presenter = createPinUnlockPresenter(this, logoutUseCase = signOut) val presenter = createPinUnlockPresenter(logoutUseCase = signOut)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -135,8 +135,7 @@ class PinUnlockPresenterTest {
dataOrNull()?.assertText(text) dataOrNull()?.assertText(text)
} }
private suspend fun createPinUnlockPresenter( private suspend fun TestScope.createPinUnlockPresenter(
scope: CoroutineScope,
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(), biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(), callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = { "" }), logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = { "" }),
@ -149,7 +148,7 @@ class PinUnlockPresenterTest {
pinCodeManager = pinCodeManager, pinCodeManager = pinCodeManager,
biometricAuthenticatorManager = biometricAuthenticatorManager, biometricAuthenticatorManager = biometricAuthenticatorManager,
logoutUseCase = logoutUseCase, logoutUseCase = logoutUseCase,
coroutineScope = scope, coroutineScope = this,
pinUnlockHelper = PinUnlockHelper(biometricAuthenticatorManager, pinCodeManager), pinUnlockHelper = PinUnlockHelper(biometricAuthenticatorManager, pinCodeManager),
) )
} }

View file

@ -30,6 +30,7 @@ import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
@ -107,6 +108,9 @@ class LoginFlowNode @AssistedInject constructor(
val isAccountCreation: Boolean, val isAccountCreation: Boolean,
) : NavTarget ) : NavTarget
@Parcelize
data object ChooseAccountProvider : NavTarget
@Parcelize @Parcelize
data object ChangeAccountProvider : NavTarget data object ChangeAccountProvider : NavTarget
@ -133,9 +137,13 @@ class LoginFlowNode @AssistedInject constructor(
) )
} }
override fun onSignIn() { override fun onSignIn(mustChooseAccountProvider: Boolean) {
backstack.push( backstack.push(
NavTarget.ConfirmAccountProvider(isAccountCreation = false) if (mustChooseAccountProvider) {
NavTarget.ChooseAccountProvider
} else {
NavTarget.ConfirmAccountProvider(isAccountCreation = false)
}
) )
} }
@ -166,6 +174,22 @@ class LoginFlowNode @AssistedInject constructor(
) )
createNode<OnBoardingNode>(buildContext, listOf(callback, inputs)) createNode<OnBoardingNode>(buildContext, listOf(callback, inputs))
} }
NavTarget.ChooseAccountProvider -> {
val callback = object : ChooseAccountProviderNode.Callback {
override fun onOidcDetails(oidcDetails: OidcDetails) {
navigateToMas(oidcDetails)
}
override fun onCreateAccountContinue(url: String) {
backstack.push(NavTarget.CreateAccount(url))
}
override fun onLoginPasswordNeeded() {
backstack.push(NavTarget.LoginPassword)
}
}
createNode<ChooseAccountProviderNode>(buildContext, listOf(callback))
}
NavTarget.QrCode -> { NavTarget.QrCode -> {
createNode<QrCodeLoginFlowNode>(buildContext) createNode<QrCodeLoginFlowNode>(buildContext)
} }

View file

@ -20,15 +20,16 @@ import javax.inject.Inject
class AccountProviderDataSource @Inject constructor( class AccountProviderDataSource @Inject constructor(
enterpriseService: EnterpriseService, enterpriseService: EnterpriseService,
) { ) {
private val defaultAccountProvider = (enterpriseService.defaultHomeserverList().firstOrNull() ?: AuthenticationConfig.MATRIX_ORG_URL) private val defaultAccountProvider =
.let { url -> (enterpriseService.defaultHomeserverList().firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } ?: AuthenticationConfig.MATRIX_ORG_URL)
AccountProvider( .let { url ->
url = url, AccountProvider(
subtitle = null, url = url,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, subtitle = null,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
) isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
} )
}
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow( private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(
defaultAccountProvider defaultAccountProvider

View file

@ -0,0 +1,75 @@
/*
* Copyright 2025 New Vector 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.accountprovider
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.login.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Text
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
*/
@Composable
fun AccountProviderOtherView(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
) {
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 44.dp)
.padding(vertical = 4.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
imageVector = CompoundIcons.Search(),
tint = ElementTheme.colors.iconPrimary,
)
Text(
modifier = Modifier
.padding(start = 16.dp)
.weight(1f),
text = stringResource(R.string.screen_change_account_provider_other),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun AccountProviderOtherViewPreview() = ElementPreview {
AccountProviderOtherView(
onClick = { },
)
}

View file

@ -23,10 +23,14 @@ open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
fun anAccountProvider( fun anAccountProvider(
url: String = AuthenticationConfig.MATRIX_ORG_URL, url: String = AuthenticationConfig.MATRIX_ORG_URL,
subtitle: String? = "Matrix.org is an open network for secure, decentralized communication.",
isPublic: Boolean = true,
isMatrixOrg: Boolean = true,
isValid: Boolean = true,
) = AccountProvider( ) = AccountProvider(
url = url, url = url,
subtitle = "Matrix.org is an open network for secure, decentralized communication.", subtitle = subtitle,
isPublic = true, isPublic = isPublic,
isMatrixOrg = true, isMatrixOrg = isMatrixOrg,
isValid = true, isValid = isValid,
) )

View file

@ -39,6 +39,7 @@ fun AccountProviderView(
item: AccountProvider, item: AccountProvider,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
selected: Boolean = false,
) { ) {
Column( Column(
modifier = modifier modifier = modifier
@ -66,7 +67,7 @@ fun AccountProviderView(
} else { } else {
RoundedIconAtom( RoundedIconAtom(
size = RoundedIconAtomSize.Medium, size = RoundedIconAtomSize.Medium,
imageVector = CompoundIcons.Search(), imageVector = CompoundIcons.Host(),
tint = ElementTheme.colors.iconPrimary, tint = ElementTheme.colors.iconPrimary,
) )
} }
@ -88,6 +89,15 @@ fun AccountProviderView(
tint = ElementTheme.colors.iconSecondary, tint = ElementTheme.colors.iconSecondary,
) )
} }
if (selected) {
Icon(
modifier = Modifier
.padding(start = 10.dp),
imageVector = CompoundIcons.Check(),
contentDescription = null,
tint = ElementTheme.colors.iconAccentPrimary,
)
}
} }
if (item.subtitle != null) { if (item.subtitle != null) {
Text( Text(

View file

@ -14,6 +14,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import io.element.android.features.login.impl.DefaultLoginUserStory import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter
@ -31,7 +32,8 @@ import javax.inject.Inject
/** /**
* This class is responsible for managing the login flow, including handling OIDC actions and * This class is responsible for managing the login flow, including handling OIDC actions and
* submitting login requests. * submitting login requests.
* It's an helper to avoid code duplication. It is used by [OnBoardingPresenter] and [ConfirmAccountProviderPresenter]. * It's an helper to avoid code duplication. It is used by [OnBoardingPresenter], [ConfirmAccountProviderPresenter]
* and [ChooseAccountProviderPresenter].
*/ */
class LoginHelper @Inject constructor( class LoginHelper @Inject constructor(
private val oidcActionFlow: OidcActionFlow, private val oidcActionFlow: OidcActionFlow,

View file

@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import io.element.android.appconfig.AuthenticationConfig import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerState import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -25,6 +26,7 @@ class ChangeAccountProviderPresenter @Inject constructor(
override fun present(): ChangeAccountProviderState { override fun present(): ChangeAccountProviderState {
val staticAccountProviderList = remember { val staticAccountProviderList = remember {
enterpriseService.defaultHomeserverList() enterpriseService.defaultHomeserverList()
.filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
.map { it.ensureProtocol() } .map { it.ensureProtocol() }
.ifEmpty { listOf(AuthenticationConfig.MATRIX_ORG_URL) } .ifEmpty { listOf(AuthenticationConfig.MATRIX_ORG_URL) }
.map { url -> .map { url ->
@ -38,9 +40,14 @@ class ChangeAccountProviderPresenter @Inject constructor(
} }
} }
val canSearchForAccountProviders = remember {
enterpriseService.canConnectToAnyHomeserver()
}
val changeServerState = changeServerPresenter.present() val changeServerState = changeServerPresenter.present()
return ChangeAccountProviderState( return ChangeAccountProviderState(
accountProviders = staticAccountProviderList, accountProviders = staticAccountProviderList,
canSearchForAccountProviders = canSearchForAccountProviders,
changeServerState = changeServerState, changeServerState = changeServerState,
) )
} }

View file

@ -13,5 +13,6 @@ import io.element.android.features.login.impl.changeserver.ChangeServerState
// Do not use default value, so no member get forgotten in the presenters. // Do not use default value, so no member get forgotten in the presenters.
data class ChangeAccountProviderState( data class ChangeAccountProviderState(
val accountProviders: List<AccountProvider>, val accountProviders: List<AccountProvider>,
val canSearchForAccountProviders: Boolean,
val changeServerState: ChangeServerState, val changeServerState: ChangeServerState,
) )

View file

@ -8,20 +8,28 @@
package io.element.android.features.login.impl.screens.changeaccountprovider package io.element.android.features.login.impl.screens.changeaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.changeserver.aChangeServerState import io.element.android.features.login.impl.changeserver.aChangeServerState
open class ChangeAccountProviderStateProvider : PreviewParameterProvider<ChangeAccountProviderState> { open class ChangeAccountProviderStateProvider : PreviewParameterProvider<ChangeAccountProviderState> {
override val values: Sequence<ChangeAccountProviderState> override val values: Sequence<ChangeAccountProviderState>
get() = sequenceOf( get() = sequenceOf(
aChangeAccountProviderState(), aChangeAccountProviderState(),
aChangeAccountProviderState(canSearchForAccountProviders = false),
// Add other state here // Add other state here
) )
} }
fun aChangeAccountProviderState() = ChangeAccountProviderState( fun aChangeAccountProviderState(
accountProviders = listOf( accountProviders: List<AccountProvider> = listOf(
anAccountProvider() anAccountProvider()
), ),
changeServerState = aChangeServerState(), canSearchForAccountProviders: Boolean = true,
changeServerState: ChangeServerState = aChangeServerState(),
) = ChangeAccountProviderState(
accountProviders = accountProviders,
canSearchForAccountProviders = canSearchForAccountProviders,
changeServerState = changeServerState,
) )

View file

@ -27,7 +27,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderOtherView
import io.element.android.features.login.impl.accountprovider.AccountProviderView import io.element.android.features.login.impl.accountprovider.AccountProviderView
import io.element.android.features.login.impl.changeserver.ChangeServerEvents import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView import io.element.android.features.login.impl.changeserver.ChangeServerView
@ -95,13 +95,11 @@ fun ChangeAccountProviderView(
) )
} }
// Other // Other
AccountProviderView( if (state.canSearchForAccountProviders) {
item = AccountProvider( AccountProviderOtherView(
url = "", onClick = onOtherProviderClick
title = stringResource(id = R.string.screen_change_account_provider_other), )
), }
onClick = onOtherProviderClick
)
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
} }
ChangeServerView( ChangeServerView(

View file

@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector 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.chooseaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
sealed interface ChooseAccountProviderEvents {
data class SelectAccountProvider(val accountProvider: AccountProvider) : ChooseAccountProviderEvents
data object Continue : ChooseAccountProviderEvents
data object ClearError : ChooseAccountProviderEvents
}

View file

@ -0,0 +1,62 @@
/*
* Copyright 2025 New Vector 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.chooseaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class)
class ChooseAccountProviderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ChooseAccountProviderPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onLoginPasswordNeeded()
fun onOidcDetails(oidcDetails: OidcDetails)
fun onCreateAccountContinue(url: String)
}
private fun onOidcDetails(oidcDetails: OidcDetails) {
plugins<Callback>().forEach { it.onOidcDetails(oidcDetails) }
}
private fun onLoginPasswordNeeded() {
plugins<Callback>().forEach { it.onLoginPasswordNeeded() }
}
private fun onCreateAccountContinue(url: String) {
plugins<Callback>().forEach { it.onCreateAccountContinue(url) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
ChooseAccountProviderView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
onOidcDetails = ::onOidcDetails,
onNeedLoginPassword = ::onLoginPasswordNeeded,
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = ::onCreateAccountContinue,
)
}
}

View file

@ -0,0 +1,80 @@
/*
* Copyright 2025 New Vector 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.chooseaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.uri.ensureProtocol
import javax.inject.Inject
class ChooseAccountProviderPresenter @Inject constructor(
private val enterpriseService: EnterpriseService,
private val loginHelper: LoginHelper,
) : Presenter<ChooseAccountProviderState> {
@Composable
override fun present(): ChooseAccountProviderState {
val localCoroutineScope = rememberCoroutineScope()
val loginMode by loginHelper.collectLoginMode()
var selectedAccountProvider: AccountProvider? by remember { mutableStateOf(null) }
fun handleEvent(event: ChooseAccountProviderEvents) {
when (event) {
ChooseAccountProviderEvents.Continue -> {
selectedAccountProvider?.let {
loginHelper.submit(
coroutineScope = localCoroutineScope,
isAccountCreation = false,
homeserverUrl = it.url,
loginHint = null,
)
}
}
is ChooseAccountProviderEvents.SelectAccountProvider -> {
// Ensure that the user do not change the server during processing
if (loginMode is AsyncData.Uninitialized) {
selectedAccountProvider = event.accountProvider
}
}
ChooseAccountProviderEvents.ClearError -> loginHelper.clearError()
}
}
val staticAccountProviderList = remember {
// The list cannot contains ANY_ACCOUNT_PROVIDER ("*") and cannot be empty at this point
enterpriseService.defaultHomeserverList()
.map { it.ensureProtocol() }
.map { url ->
AccountProvider(
url = url,
subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
isValid = true,
)
}
}
return ChooseAccountProviderState(
accountProviders = staticAccountProviderList,
selectedAccountProvider = selectedAccountProvider,
loginMode = loginMode,
eventSink = ::handleEvent,
)
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2025 New Vector 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.chooseaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData
// Do not use default value, so no member get forgotten in the presenters.
data class ChooseAccountProviderState(
val accountProviders: List<AccountProvider>,
val selectedAccountProvider: AccountProvider?,
val loginMode: AsyncData<LoginMode>,
val eventSink: (ChooseAccountProviderEvents) -> Unit,
) {
val submitEnabled: Boolean
get() = selectedAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
}

View file

@ -0,0 +1,77 @@
/*
* Copyright 2025 New Vector 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.chooseaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData
open class ChooseAccountProviderStateProvider : PreviewParameterProvider<ChooseAccountProviderState> {
private val server1 = anAccountProvider(
url = "https://server1.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
)
private val server2 = anAccountProvider(
url = "https://server2.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
)
private val server3 = anAccountProvider(
url = "https://server3.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
)
override val values: Sequence<ChooseAccountProviderState>
get() = sequenceOf(
aChooseAccountProviderState(
accountProviders = listOf(
server1,
server2,
server3,
)
),
aChooseAccountProviderState(
accountProviders = listOf(
server1,
server2,
server3,
),
selectedAccountProvider = server2,
),
aChooseAccountProviderState(
accountProviders = listOf(
server1,
server2,
server3,
),
selectedAccountProvider = server2,
loginMode = AsyncData.Loading(),
),
// Add other state here
)
}
fun aChooseAccountProviderState(
accountProviders: List<AccountProvider> = listOf(
anAccountProvider()
),
selectedAccountProvider: AccountProvider? = null,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
eventSink: (ChooseAccountProviderEvents) -> Unit = {},
) = ChooseAccountProviderState(
accountProviders = accountProviders,
selectedAccountProvider = selectedAccountProvider,
loginMode = loginMode,
eventSink = eventSink,
)

View file

@ -0,0 +1,150 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.login.impl.screens.chooseaccountprovider
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.accountprovider.AccountProviderView
import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ChooseAccountProviderView(
state: ChooseAccountProviderState,
onBackClick: () -> Unit,
onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginMode) {
derivedStateOf {
state.loginMode is AsyncData.Loading
}
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackClick) }
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(state = rememberScrollState())
) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp),
iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()),
title = stringResource(id = R.string.screen_server_confirmation_title_picker_mode),
subTitle = null,
)
state.accountProviders.forEach { item ->
val alteredItem = if (item.isMatrixOrg) {
// Set the subtitle from the resource
item.copy(
subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
)
} else {
item
}
AccountProviderView(
item = alteredItem,
selected = item == state.selectedAccountProvider,
onClick = {
state.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(item))
}
)
}
Spacer(Modifier.height(32.dp))
// Flexible spacing to keep the submit button at the bottom
Spacer(modifier = Modifier.weight(1f))
Button(
text = stringResource(id = CommonStrings.action_continue),
showProgress = isLoading,
onClick = {
state.eventSink(ChooseAccountProviderEvents.Continue)
},
enabled = state.submitEnabled || isLoading,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(48.dp))
}
LoginModeView(
loginMode = state.loginMode,
onClearError = {
state.eventSink(ChooseAccountProviderEvents.ClearError)
},
onLearnMoreClick = onLearnMoreClick,
onOidcDetails = onOidcDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun ChooseAccountProviderViewPreview(@PreviewParameter(ChooseAccountProviderStateProvider::class) state: ChooseAccountProviderState) = ElementPreview {
ChooseAccountProviderView(
state = state,
onBackClick = { },
onLearnMoreClick = { },
onOidcDetails = { },
onNeedLoginPassword = { },
onCreateAccountContinue = { },
)
}

View file

@ -34,7 +34,7 @@ class OnBoardingNode @AssistedInject constructor(
) { ) {
interface Callback : Plugin { interface Callback : Plugin {
fun onSignUp() fun onSignUp()
fun onSignIn() fun onSignIn(mustChooseAccountProvider: Boolean)
fun onSignInWithQrCode() fun onSignInWithQrCode()
fun onReportProblem() fun onReportProblem()
fun onLoginPasswordNeeded() fun onLoginPasswordNeeded()
@ -53,8 +53,8 @@ class OnBoardingNode @AssistedInject constructor(
params = params, params = params,
) )
private fun onSignIn() { private fun onSignIn(mustChooseAccountProvider: Boolean) {
plugins<Callback>().forEach { it.onSignIn() } plugins<Callback>().forEach { it.onSignIn(mustChooseAccountProvider) }
} }
private fun onSignUp() { private fun onSignUp() {

View file

@ -16,6 +16,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.appconfig.OnBoardingConfig import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.features.login.impl.login.LoginHelper import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -27,6 +29,7 @@ class OnBoardingPresenter @AssistedInject constructor(
@Assisted private val params: OnBoardingNode.Params, @Assisted private val params: OnBoardingNode.Params,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val enterpriseService: EnterpriseService,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val loginHelper: LoginHelper, private val loginHelper: LoginHelper,
) : Presenter<OnBoardingState> { ) : Presenter<OnBoardingState> {
@ -37,15 +40,33 @@ class OnBoardingPresenter @AssistedInject constructor(
): OnBoardingPresenter ): OnBoardingPresenter
} }
private val defaultAccountProvider = params.accountProvider
private val loginHint = params.loginHint
@Composable @Composable
override fun present(): OnBoardingState { override fun present(): OnBoardingState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val forcedAccountProvider = remember {
val canLoginWithQrCode by produceState(initialValue = false) { // If defaultHomeserverList() returns a singleton list, this is the default account provider.
value = defaultAccountProvider == null && // In this case, the user can sign in using this homeserver, or use QrCode login
enterpriseService.defaultHomeserverList().singleOrNull()
}
val canConnectToAnyHomeserver = remember {
enterpriseService.canConnectToAnyHomeserver()
}
val mustChooseAccountProvider = remember {
!canConnectToAnyHomeserver && enterpriseService.defaultHomeserverList().size > 1
}
val linkAccountProvider by produceState<String?>(initialValue = null) {
// Account provider from the link, if allowed by the enterprise service
value = params.accountProvider?.takeIf {
enterpriseService.isAllowedToConnectToHomeserver(it)
}
}
val defaultAccountProvider = remember(linkAccountProvider) {
// If there is a forced account provider, this is the default account provider
// Else use the account provider passed in the params if any and if allowed
forcedAccountProvider ?: linkAccountProvider
}
val canLoginWithQrCode by produceState(initialValue = false, linkAccountProvider) {
value = linkAccountProvider == null &&
featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin) featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin)
} }
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() } val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
@ -58,7 +79,7 @@ class OnBoardingPresenter @AssistedInject constructor(
coroutineScope = localCoroutineScope, coroutineScope = localCoroutineScope,
isAccountCreation = false, isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider, homeserverUrl = event.defaultAccountProvider,
loginHint = loginHint, loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
) )
OnBoardingEvents.ClearError -> loginHelper.clearError() OnBoardingEvents.ClearError -> loginHelper.clearError()
} }
@ -67,8 +88,9 @@ class OnBoardingPresenter @AssistedInject constructor(
return OnBoardingState( return OnBoardingState(
productionApplicationName = buildMeta.productionApplicationName, productionApplicationName = buildMeta.productionApplicationName,
defaultAccountProvider = defaultAccountProvider, defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider,
canLoginWithQrCode = canLoginWithQrCode, canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = defaultAccountProvider == null && OnBoardingConfig.CAN_CREATE_ACCOUNT, canCreateAccount = defaultAccountProvider == null && canConnectToAnyHomeserver && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug, canReportBug = canReportBug,
loginMode = loginMode, loginMode = loginMode,
eventSink = ::handleEvent, eventSink = ::handleEvent,

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.architecture.AsyncData
data class OnBoardingState( data class OnBoardingState(
val productionApplicationName: String, val productionApplicationName: String,
val defaultAccountProvider: String?, val defaultAccountProvider: String?,
val mustChooseAccountProvider: Boolean,
val canLoginWithQrCode: Boolean, val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean, val canCreateAccount: Boolean,
val canReportBug: Boolean, val canReportBug: Boolean,

View file

@ -26,6 +26,7 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
fun anOnBoardingState( fun anOnBoardingState(
productionApplicationName: String = "Element", productionApplicationName: String = "Element",
defaultAccountProvider: String? = null, defaultAccountProvider: String? = null,
mustChooseAccountProvider: Boolean = false,
canLoginWithQrCode: Boolean = false, canLoginWithQrCode: Boolean = false,
canCreateAccount: Boolean = false, canCreateAccount: Boolean = false,
canReportBug: Boolean = false, canReportBug: Boolean = false,
@ -34,6 +35,7 @@ fun anOnBoardingState(
) = OnBoardingState( ) = OnBoardingState(
productionApplicationName = productionApplicationName, productionApplicationName = productionApplicationName,
defaultAccountProvider = defaultAccountProvider, defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider,
canLoginWithQrCode = canLoginWithQrCode, canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = canCreateAccount, canCreateAccount = canCreateAccount,
canReportBug = canReportBug, canReportBug = canReportBug,

View file

@ -56,7 +56,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun OnBoardingView( fun OnBoardingView(
state: OnBoardingState, state: OnBoardingState,
onSignInWithQrCode: () -> Unit, onSignInWithQrCode: () -> Unit,
onSignIn: () -> Unit, onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
onCreateAccount: () -> Unit, onCreateAccount: () -> Unit,
onOidcDetails: (OidcDetails) -> Unit, onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit, onNeedLoginPassword: () -> Unit,
@ -143,7 +143,7 @@ private fun OnBoardingContent(state: OnBoardingState) {
private fun OnBoardingButtons( private fun OnBoardingButtons(
state: OnBoardingState, state: OnBoardingState,
onSignInWithQrCode: () -> Unit, onSignInWithQrCode: () -> Unit,
onSignIn: () -> Unit, onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
onCreateAccount: () -> Unit, onCreateAccount: () -> Unit,
onReportProblem: () -> Unit, onReportProblem: () -> Unit,
) { ) {
@ -171,7 +171,9 @@ private fun OnBoardingButtons(
if (defaultAccountProvider == null) { if (defaultAccountProvider == null) {
Button( Button(
text = stringResource(id = signInButtonStringRes), text = stringResource(id = signInButtonStringRes),
onClick = onSignIn, onClick = {
onSignIn(state.mustChooseAccountProvider)
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag(TestTags.onBoardingSignIn) .testTag(TestTags.onBoardingSignIn)

View file

@ -89,5 +89,6 @@ Try signing in manually, or scan the QR code with another device."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix is an open network for secure, decentralised communication."</string> <string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix is an open network for secure, decentralised communication."</string>
<string name="screen_server_confirmation_message_register">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string> <string name="screen_server_confirmation_message_register">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_server_confirmation_title_login">"Youre about to sign in to %1$s"</string> <string name="screen_server_confirmation_title_login">"Youre about to sign in to %1$s"</string>
<string name="screen_server_confirmation_title_picker_mode">"Choose account provider"</string>
<string name="screen_server_confirmation_title_register">"Youre about to create an account on %1$s"</string> <string name="screen_server_confirmation_title_register">"Youre about to create an account on %1$s"</string>
</resources> </resources>

View file

@ -10,6 +10,7 @@ package io.element.android.features.login.impl.accountprovider
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -60,6 +61,28 @@ class AccountProviderDataSourceTest {
} }
} }
@Test
fun `present - ensure that default homeserver is not star char`() = runTest {
val sut = AccountProviderDataSource(
FakeEnterpriseService(
defaultHomeserverListResult = { listOf(EnterpriseService.ANY_ACCOUNT_PROVIDER, AuthenticationConfig.MATRIX_ORG_URL) }
)
)
sut.flow.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
AccountProvider(
url = AuthenticationConfig.MATRIX_ORG_URL,
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
isValid = false,
)
)
}
}
@Test @Test
fun `present - user change and reset`() = runTest { fun `present - user change and reset`() = runTest {
val sut = AccountProviderDataSource(FakeEnterpriseService()) val sut = AccountProviderDataSource(FakeEnterpriseService())

View file

@ -11,9 +11,12 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.aChangeServerState import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -27,7 +30,9 @@ class ChangeAccountProviderPresenterTest {
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = ChangeAccountProviderPresenter( val presenter = ChangeAccountProviderPresenter(
changeServerPresenter = { aChangeServerState() }, changeServerPresenter = { aChangeServerState() },
enterpriseService = FakeEnterpriseService(), enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { emptyList() }
),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -45,6 +50,75 @@ class ChangeAccountProviderPresenterTest {
) )
) )
) )
assertThat(initialState.canSearchForAccountProviders).isTrue()
}
}
@Test
fun `present - fixed list of account providers`() = runTest {
val presenter = ChangeAccountProviderPresenter(
changeServerPresenter = { aChangeServerState() },
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = {
listOf(AN_ACCOUNT_PROVIDER, AN_ACCOUNT_PROVIDER_2)
}
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.accountProviders).isEqualTo(
listOf(
AccountProvider(
url = "https://matrix.org",
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
isValid = true,
),
AccountProvider(
url = "https://element.io",
title = "element.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
isValid = true,
)
)
)
assertThat(initialState.canSearchForAccountProviders).isFalse()
}
}
@Test
fun `present - opened list of account providers`() = runTest {
val presenter = ChangeAccountProviderPresenter(
changeServerPresenter = { aChangeServerState() },
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = {
listOf(AN_ACCOUNT_PROVIDER, EnterpriseService.ANY_ACCOUNT_PROVIDER)
}
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.accountProviders).isEqualTo(
listOf(
AccountProvider(
url = "https://matrix.org",
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
isValid = true,
)
)
)
assertThat(initialState.canSearchForAccountProviders).isTrue()
} }
} }
} }

View file

@ -0,0 +1,167 @@
/*
* Copyright 2025 New Vector 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.chooseaccountprovider
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.screens.onboarding.createLoginHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ChooseAccountProviderPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
companion object {
private const val ACCOUNT_PROVIDER_FROM_CONFIG_1 = AN_ACCOUNT_PROVIDER_2
private const val ACCOUNT_PROVIDER_FROM_CONFIG_2 = AN_ACCOUNT_PROVIDER_3
val accountProvider1 = AccountProvider(
url = ACCOUNT_PROVIDER_FROM_CONFIG_1.ensureProtocol(),
subtitle = null,
isPublic = false,
isMatrixOrg = false,
isValid = true,
)
val accountProvider2 = AccountProvider(
url = ACCOUNT_PROVIDER_FROM_CONFIG_2.ensureProtocol(),
subtitle = null,
isPublic = false,
isMatrixOrg = false,
isValid = true,
)
}
@Test
fun `present - ensure initial conditions`() {
assertThat(
setOf(
ACCOUNT_PROVIDER_FROM_CONFIG_1,
ACCOUNT_PROVIDER_FROM_CONFIG_2,
).size
).isEqualTo(2)
}
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter(
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
),
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.accountProviders).containsExactly(
accountProvider1,
accountProvider2,
)
assertThat(initialState.selectedAccountProvider).isNull()
}
}
@Test
fun `present - Continue when no account provider is selected has no effect`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
val presenter = createPresenter(
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
),
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
)
presenter.test {
awaitItem().also {
assertThat(it.selectedAccountProvider).isNull()
it.eventSink(ChooseAccountProviderEvents.Continue)
expectNoEvents()
}
}
}
@Test
fun `present - select account provider and continue - error then clear error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
val presenter = createPresenter(
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
),
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
)
presenter.test {
awaitItem().also {
assertThat(it.selectedAccountProvider).isNull()
it.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(accountProvider1))
}
awaitItem().also {
assertThat(it.selectedAccountProvider).isEqualTo(accountProvider1)
authenticationService.givenChangeServerError(A_THROWABLE)
it.eventSink(ChooseAccountProviderEvents.Continue)
skipItems(1) // Loading
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
// Assert the error is then cleared
submittedState.eventSink(ChooseAccountProviderEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
}
@Test
fun `present - default account provider - select account provider during login has no effect`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
val presenter = createPresenter(
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
),
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
)
presenter.test {
awaitItem().also {
assertThat(it.selectedAccountProvider).isNull()
it.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(accountProvider1))
}
awaitItem().also {
assertThat(it.selectedAccountProvider).isEqualTo(accountProvider1)
it.eventSink(ChooseAccountProviderEvents.Continue)
}
awaitItem().also {
assertThat(it.loginMode.isLoading()).isTrue()
it.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(accountProvider2))
}
expectNoEvents()
}
}
}
private fun createPresenter(
enterpriseService: EnterpriseService = FakeEnterpriseService(),
loginHelper: LoginHelper = createLoginHelper(),
) = ChooseAccountProviderPresenter(
enterpriseService = enterpriseService,
loginHelper = loginHelper,
)

View file

@ -0,0 +1,102 @@
/*
* Copyright 2025 New Vector 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.chooseaccountprovider
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
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.features.login.impl.accountprovider.anAccountProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class ChooseAccountProviderViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventSink = EventsRecorder<ChooseAccountProviderEvents>(expectEvents = false)
ensureCalledOnce {
rule.setChooseAccountProviderView(
state = aChooseAccountProviderState(
eventSink = eventSink,
),
onBackClick = it,
)
rule.pressBack()
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `selecting an account provider emits the the expected event`() {
val eventSink = EventsRecorder<ChooseAccountProviderEvents>()
rule.setChooseAccountProviderView(
state = aChooseAccountProviderState(
accountProviders = listOf(
ChooseAccountProviderPresenterTest.accountProvider1,
ChooseAccountProviderPresenterTest.accountProvider2,
),
selectedAccountProvider = anAccountProvider(),
eventSink = eventSink,
),
)
rule.onNodeWithText(ChooseAccountProviderPresenterTest.accountProvider1.title).performClick()
eventSink.assertSingle(ChooseAccountProviderEvents.SelectAccountProvider(ChooseAccountProviderPresenterTest.accountProvider1))
}
@Test
fun `when error is displayed - closing the dialog emits the expected event`() {
val eventSink = EventsRecorder<ChooseAccountProviderEvents>()
rule.setChooseAccountProviderView(
state = aChooseAccountProviderState(
loginMode = AsyncData.Failure(AN_EXCEPTION),
eventSink = eventSink,
),
)
rule.clickOn(CommonStrings.action_ok)
eventSink.assertSingle(ChooseAccountProviderEvents.ClearError)
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChooseAccountProviderView(
state: ChooseAccountProviderState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(),
onNeedLoginPassword: () -> Unit = EnsureNeverCalled(),
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onCreateAccountContinue: (url: String) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
ChooseAccountProviderView(
state = state,
onBackClick = onBackClick,
onOidcDetails = onOidcDetails,
onNeedLoginPassword = onNeedLoginPassword,
onLearnMoreClick = onLearnMoreClick,
onCreateAccountContinue = onCreateAccountContinue,
)
}
}
}

View file

@ -9,6 +9,8 @@ package io.element.android.features.login.impl.screens.onboarding
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.OnBoardingConfig import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.DefaultLoginUserStory import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.login.LoginHelper import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
@ -19,6 +21,9 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_LOGIN_HINT import io.element.android.libraries.matrix.test.A_LOGIN_HINT
import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_THROWABLE
@ -36,6 +41,23 @@ class OnBoardingPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
companion object {
private const val ACCOUNT_PROVIDER_FROM_LINK = AN_ACCOUNT_PROVIDER
private const val ACCOUNT_PROVIDER_FROM_CONFIG = AN_ACCOUNT_PROVIDER_2
private const val ACCOUNT_PROVIDER_FROM_CONFIG_2 = AN_ACCOUNT_PROVIDER_3
}
@Test
fun `present - ensure initial conditions`() {
assertThat(
setOf(
ACCOUNT_PROVIDER_FROM_LINK,
ACCOUNT_PROVIDER_FROM_CONFIG,
ACCOUNT_PROVIDER_FROM_CONFIG_2,
).size
).isEqualTo(3)
}
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val buildMeta = aBuildMeta( val buildMeta = aBuildMeta(
@ -50,10 +72,14 @@ class OnBoardingPresenterTest {
val presenter = createPresenter( val presenter = createPresenter(
buildMeta = buildMeta, buildMeta = buildMeta,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
),
rageshakeFeatureAvailability = { true }, rageshakeFeatureAvailability = { true },
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.defaultAccountProvider).isNull()
assertThat(initialState.canLoginWithQrCode).isFalse() assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B") assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT) assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
@ -74,22 +100,79 @@ class OnBoardingPresenterTest {
} }
@Test @Test
fun `present - default account provider`() = runTest { fun `present - opening the app using link with allowed account provider, and the app does not force account provider`() = runTest {
val presenter = createPresenter( val presenter = createPresenter(
params = OnBoardingNode.Params( params = OnBoardingNode.Params(
accountProvider = A_HOMESERVER_URL, accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
loginHint = null, loginHint = null,
), ),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.QrCodeLogin.key to true),
),
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
isAllowedToConnectToHomeserverResult = { true },
),
) )
presenter.test { presenter.test {
skipItems(3)
awaitItem().also { awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL) assertThat(it.defaultAccountProvider).isEqualTo(ACCOUNT_PROVIDER_FROM_LINK)
assertThat(it.canLoginWithQrCode).isFalse() assertThat(it.canLoginWithQrCode).isFalse()
assertThat(it.canCreateAccount).isFalse() assertThat(it.canCreateAccount).isFalse()
} }
} }
} }
@Test
fun `present - opening the app using link with not allowed account provider, and the app does not force account provider`() = runTest {
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
loginHint = null,
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.QrCodeLogin.key to true),
),
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
isAllowedToConnectToHomeserverResult = { false },
),
)
presenter.test {
skipItems(1)
awaitItem().also {
assertThat(it.defaultAccountProvider).isNull()
assertThat(it.canLoginWithQrCode).isTrue()
assertThat(it.canCreateAccount).isFalse()
}
}
}
@Test
fun `present - opening the app using link, and the app forces account provider`() = runTest {
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
loginHint = null,
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.QrCodeLogin.key to true),
),
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG) },
)
)
presenter.test {
skipItems(1)
awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(ACCOUNT_PROVIDER_FROM_CONFIG)
assertThat(it.canLoginWithQrCode).isTrue()
assertThat(it.canCreateAccount).isFalse()
}
}
}
@Test @Test
fun `present - default account provider - login and clear error`() = runTest { fun `present - default account provider - login and clear error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService() val authenticationService = FakeMatrixAuthenticationService()
@ -98,11 +181,15 @@ class OnBoardingPresenterTest {
accountProvider = A_HOMESERVER_URL, accountProvider = A_HOMESERVER_URL,
loginHint = A_LOGIN_HINT, loginHint = A_LOGIN_HINT,
), ),
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
),
loginHelper = createLoginHelper( loginHelper = createLoginHelper(
authenticationService = authenticationService, authenticationService = authenticationService,
), ),
) )
presenter.test { presenter.test {
skipItems(3)
awaitItem().also { awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL) assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
authenticationService.givenChangeServerError(A_THROWABLE) authenticationService.givenChangeServerError(A_THROWABLE)
@ -126,12 +213,14 @@ private fun createPresenter(
params: OnBoardingNode.Params = OnBoardingNode.Params(null, null), params: OnBoardingNode.Params = OnBoardingNode.Params(null, null),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
rageshakeFeatureAvailability: () -> Boolean = { true }, rageshakeFeatureAvailability: () -> Boolean = { true },
loginHelper: LoginHelper = createLoginHelper(), loginHelper: LoginHelper = createLoginHelper(),
) = OnBoardingPresenter( ) = OnBoardingPresenter(
params = params, params = params,
buildMeta = buildMeta, buildMeta = buildMeta,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
enterpriseService = enterpriseService,
rageshakeFeatureAvailability = rageshakeFeatureAvailability, rageshakeFeatureAvailability = rageshakeFeatureAvailability,
loginHelper = loginHelper, loginHelper = loginHelper,
) )

View file

@ -23,6 +23,7 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -56,10 +57,28 @@ class OnboardingViewTest {
} }
@Test @Test
fun `when can login with QR code - clicking on sign in manually calls the expected callback`() { fun `when can login with QR code - clicking on sign in manually calls the expected callback - can search account provider`() {
ensureCalledOnce { callback -> `when can login with QR code - clicking on sign in manually calls the expected callback`(
mustChooseAccountProvider = false,
)
}
@Test
fun `when can login with QR code - clicking on sign in manually calls the expected callback - cannot search account provider`() {
`when can login with QR code - clicking on sign in manually calls the expected callback`(
mustChooseAccountProvider = true,
)
}
private fun `when can login with QR code - clicking on sign in manually calls the expected callback`(
mustChooseAccountProvider: Boolean,
) {
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
rule.setOnboardingView( rule.setOnboardingView(
state = anOnBoardingState(canLoginWithQrCode = true), state = anOnBoardingState(
canLoginWithQrCode = true,
mustChooseAccountProvider = mustChooseAccountProvider,
),
onSignIn = callback, onSignIn = callback,
) )
rule.clickOn(R.string.screen_onboarding_sign_in_manually) rule.clickOn(R.string.screen_onboarding_sign_in_manually)
@ -67,12 +86,28 @@ class OnboardingViewTest {
} }
@Test @Test
fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`() { fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback - can search account provider`() {
ensureCalledOnce { callback -> `when cannot login with QR code or create account - clicking on continue calls the sign in callback`(
mustChooseAccountProvider = false,
)
}
@Test
fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback - cannot search account provider`() {
`when cannot login with QR code or create account - clicking on continue calls the sign in callback`(
mustChooseAccountProvider = true,
)
}
private fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`(
mustChooseAccountProvider: Boolean,
) {
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
rule.setOnboardingView( rule.setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
canLoginWithQrCode = false, canLoginWithQrCode = false,
canCreateAccount = false, canCreateAccount = false,
mustChooseAccountProvider = mustChooseAccountProvider,
), ),
onSignIn = callback, onSignIn = callback,
) )
@ -137,7 +172,7 @@ class OnboardingViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
state: OnBoardingState, state: OnBoardingState,
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(), onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
onSignIn: () -> Unit = EnsureNeverCalled(), onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
onCreateAccount: () -> Unit = EnsureNeverCalled(), onCreateAccount: () -> Unit = EnsureNeverCalled(),
onReportProblem: () -> Unit = EnsureNeverCalled(), onReportProblem: () -> Unit = EnsureNeverCalled(),
onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(), onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(),

View file

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

View file

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

View file

@ -513,7 +513,17 @@ class AttachmentsPreviewPresenterTest {
@Test @Test
fun `present - dismissing the progress dialog stops media upload with media queue`() = runTest { fun `present - dismissing the progress dialog stops media upload with media queue`() = runTest {
val onDoneListenerResult = lambdaRecorder<Unit> {} val onDoneListenerResult = lambdaRecorder<Unit> {}
val presenter = createAttachmentsPreviewPresenter(mediaUploadOnSendQueueEnabled = true, onDoneListener = onDoneListenerResult) val presenter = createAttachmentsPreviewPresenter(
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendFileLambda = { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
}
),
mediaUploadOnSendQueueEnabled = true,
onDoneListener = onDoneListenerResult,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {

View file

@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -91,13 +91,12 @@ class ForwardMessagesPresenterTest {
} }
} }
private fun CoroutineScope.aForwardMessagesPresenter( private fun TestScope.aForwardMessagesPresenter(
eventId: EventId = AN_EVENT_ID, eventId: EventId = AN_EVENT_ID,
fakeRoom: FakeJoinedRoom = FakeJoinedRoom(), fakeRoom: FakeJoinedRoom = FakeJoinedRoom(),
coroutineScope: CoroutineScope = this,
) = ForwardMessagesPresenter( ) = ForwardMessagesPresenter(
eventId = eventId.value, eventId = eventId.value,
timelineProvider = LiveTimelineProvider(fakeRoom), timelineProvider = LiveTimelineProvider(fakeRoom),
appCoroutineScope = coroutineScope, appCoroutineScope = this,
) )
} }

View file

@ -63,6 +63,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3 import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4 import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
@ -101,8 +102,8 @@ import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -132,7 +133,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createPresenter(this) val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -147,7 +148,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - toggle fullscreen`() = runTest { fun `present - toggle fullscreen`() = runTest {
val presenter = createPresenter(this) val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -163,7 +164,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - change message`() = runTest { fun `present - change message`() = runTest {
val presenter = createPresenter(this) val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -186,7 +187,6 @@ class MessageComposerPresenterTest {
this.saveDraftLambda = updateDraftLambda this.saveDraftLambda = updateDraftLambda
} }
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
draftService = draftService, draftService = draftService,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -229,7 +229,6 @@ class MessageComposerPresenterTest {
this.saveDraftLambda = updateDraftLambda this.saveDraftLambda = updateDraftLambda
} }
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
draftService = draftService, draftService = draftService,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -273,7 +272,6 @@ class MessageComposerPresenterTest {
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
) )
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
room = joinedRoom, room = joinedRoom,
isRichTextEditorEnabled = false, isRichTextEditorEnabled = false,
) )
@ -314,7 +312,6 @@ class MessageComposerPresenterTest {
this.saveDraftLambda = updateDraftLambda this.saveDraftLambda = updateDraftLambda
} }
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
draftService = draftService, draftService = draftService,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -346,7 +343,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - change mode to reply`() = runTest { fun `present - change mode to reply`() = runTest {
val presenter = createPresenter(this) val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -362,7 +359,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - cancel reply`() = runTest { fun `present - cancel reply`() = runTest {
val presenter = createPresenter(this) val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -382,7 +379,6 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - send message with rich text enabled`() = runTest { fun `present - send message with rich text enabled`() = runTest {
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
room = FakeJoinedRoom( room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply { liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _ -> Result.success(Unit) } sendMessageLambda = { _, _, _ -> Result.success(Unit) }
@ -417,7 +413,6 @@ class MessageComposerPresenterTest {
fun `present - send message with plain text enabled`() = runTest { fun `present - send message with plain text enabled`() = runTest {
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("") }) val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("") })
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
isRichTextEditorEnabled = false, isRichTextEditorEnabled = false,
room = FakeJoinedRoom( room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply { liveTimeline = FakeTimeline().apply {
@ -464,7 +459,6 @@ class MessageComposerPresenterTest {
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
) )
val presenter = createPresenter( val presenter = createPresenter(
this,
joinedRoom, joinedRoom,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -520,7 +514,6 @@ class MessageComposerPresenterTest {
editMessageLambda = roomEditMessageLambda, editMessageLambda = roomEditMessageLambda,
) )
val presenter = createPresenter( val presenter = createPresenter(
this,
joinedRoom, joinedRoom,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -576,7 +569,6 @@ class MessageComposerPresenterTest {
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
val presenter = createPresenter( val presenter = createPresenter(
this,
joinedRoom, joinedRoom,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -628,7 +620,6 @@ class MessageComposerPresenterTest {
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
) )
val presenter = createPresenter( val presenter = createPresenter(
this,
joinedRoom, joinedRoom,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -666,7 +657,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - Open attachments menu`() = runTest { fun `present - Open attachments menu`() = runTest {
val presenter = createPresenter(this) val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -679,7 +670,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - Dismiss attachments menu`() = runTest { fun `present - Dismiss attachments menu`() = runTest {
val presenter = createPresenter(this) val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -702,7 +693,6 @@ class MessageComposerPresenterTest {
onPreviewAttachmentLambda = onPreviewAttachmentLambda onPreviewAttachmentLambda = onPreviewAttachmentLambda
) )
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
room = room, room = room,
navigator = navigator, navigator = navigator,
) )
@ -743,7 +733,6 @@ class MessageComposerPresenterTest {
onPreviewAttachmentLambda = onPreviewAttachmentLambda onPreviewAttachmentLambda = onPreviewAttachmentLambda
) )
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
room = room, room = room,
navigator = navigator, navigator = navigator,
) )
@ -777,7 +766,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - Pick media from gallery & cancel does nothing`() = runTest { fun `present - Pick media from gallery & cancel does nothing`() = runTest {
val presenter = createPresenter(this) val presenter = createPresenter()
with(pickerProvider) { with(pickerProvider) {
givenResult(null) // Simulate a user canceling the flow givenResult(null) // Simulate a user canceling the flow
givenMimeType(MimeTypes.Images) givenMimeType(MimeTypes.Images)
@ -801,7 +790,6 @@ class MessageComposerPresenterTest {
onPreviewAttachmentLambda = onPreviewAttachmentLambda onPreviewAttachmentLambda = onPreviewAttachmentLambda
) )
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
room = room, room = room,
navigator = navigator, navigator = navigator,
) )
@ -819,7 +807,7 @@ class MessageComposerPresenterTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
) )
val presenter = createPresenter(this, room = room) val presenter = createPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -838,7 +826,7 @@ class MessageComposerPresenterTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
) )
val presenter = createPresenter(this, room = room) val presenter = createPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -863,7 +851,6 @@ class MessageComposerPresenterTest {
onPreviewAttachmentLambda = onPreviewAttachmentLambda onPreviewAttachmentLambda = onPreviewAttachmentLambda
) )
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
room = room, room = room,
permissionPresenter = permissionPresenter, permissionPresenter = permissionPresenter,
navigator = navigator, navigator = navigator,
@ -888,7 +875,6 @@ class MessageComposerPresenterTest {
onPreviewAttachmentLambda = onPreviewAttachmentLambda onPreviewAttachmentLambda = onPreviewAttachmentLambda
) )
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
room = room, room = room,
permissionPresenter = permissionPresenter, permissionPresenter = permissionPresenter,
navigator = navigator, navigator = navigator,
@ -915,7 +901,6 @@ class MessageComposerPresenterTest {
onPreviewAttachmentLambda = onPreviewAttachmentLambda onPreviewAttachmentLambda = onPreviewAttachmentLambda
) )
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
room = room, room = room,
permissionPresenter = permissionPresenter, permissionPresenter = permissionPresenter,
navigator = navigator, navigator = navigator,
@ -940,7 +925,6 @@ class MessageComposerPresenterTest {
onPreviewAttachmentLambda = onPreviewAttachmentLambda onPreviewAttachmentLambda = onPreviewAttachmentLambda
) )
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
room = room, room = room,
permissionPresenter = permissionPresenter, permissionPresenter = permissionPresenter,
navigator = navigator, navigator = navigator,
@ -961,7 +945,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - errors are tracked`() = runTest { fun `present - errors are tracked`() = runTest {
val testException = Exception("Test error") val testException = Exception("Test error")
val presenter = createPresenter(this) val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -973,7 +957,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - ToggleTextFormatting toggles text formatting`() = runTest { fun `present - ToggleTextFormatting toggles text formatting`() = runTest {
val presenter = createPresenter(this, isRichTextEditorEnabled = false) val presenter = createPresenter(isRichTextEditorEnabled = false)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -1017,7 +1001,7 @@ class MessageComposerPresenterTest {
) )
givenRoomInfo(aRoomInfo(isDirect = false)) givenRoomInfo(aRoomInfo(isDirect = false))
} }
val presenter = createPresenter(this, room) val presenter = createPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -1078,7 +1062,7 @@ class MessageComposerPresenterTest {
) )
) )
} }
val presenter = createPresenter(this, room) val presenter = createPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -1094,7 +1078,6 @@ class MessageComposerPresenterTest {
fun `present - InsertSuggestion`() = runTest { fun `present - InsertSuggestion`() = runTest {
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this,
permalinkBuilder = FakePermalinkBuilder( permalinkBuilder = FakePermalinkBuilder(
permalinkForUserLambda = { permalinkForUserLambda = {
Result.success("https://matrix.to/#/${A_USER_ID_2.value}") Result.success("https://matrix.to/#/${A_USER_ID_2.value}")
@ -1134,7 +1117,7 @@ class MessageComposerPresenterTest {
liveTimeline = timeline, liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
) )
val presenter = createPresenter(room = room, coroutineScope = this) val presenter = createPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -1197,7 +1180,16 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - send uri`() = runTest { fun `present - send uri`() = runTest {
val presenter = createPresenter(this) val presenter = createPresenter(
room = FakeJoinedRoom(
typingNoticeResult = { Result.success(Unit) },
liveTimeline = FakeTimeline().apply {
sendFileLambda = { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
}
),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present() val state = presenter.present()
remember(state, state.textEditorState.messageHtml()) { state } remember(state, state.textEditorState.messageHtml()) { state }
@ -1214,7 +1206,7 @@ class MessageComposerPresenterTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
typingNoticeResult = typingNoticeResult, typingNoticeResult = typingNoticeResult,
) )
val presenter = createPresenter(room = room, coroutineScope = this) val presenter = createPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -1240,7 +1232,7 @@ class MessageComposerPresenterTest {
val store = InMemorySessionPreferencesStore( val store = InMemorySessionPreferencesStore(
isSendTypingNotificationsEnabled = false isSendTypingNotificationsEnabled = false
) )
val presenter = createPresenter(room = room, sessionPreferencesStore = store, coroutineScope = this) val presenter = createPresenter(room = room, sessionPreferencesStore = store)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -1258,7 +1250,7 @@ class MessageComposerPresenterTest {
val composerDraftService = FakeComposerDraftService().apply { val composerDraftService = FakeComposerDraftService().apply {
this.loadDraftLambda = loadDraftLambda this.loadDraftLambda = loadDraftLambda
} }
val presenter = createPresenter(draftService = composerDraftService, coroutineScope = this) val presenter = createPresenter(draftService = composerDraftService)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -1283,7 +1275,6 @@ class MessageComposerPresenterTest {
val presenter = createPresenter( val presenter = createPresenter(
draftService = composerDraftService, draftService = composerDraftService,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
coroutineScope = this
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -1317,7 +1308,6 @@ class MessageComposerPresenterTest {
val presenter = createPresenter( val presenter = createPresenter(
draftService = composerDraftService, draftService = composerDraftService,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
coroutineScope = this
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -1351,7 +1341,6 @@ class MessageComposerPresenterTest {
val presenter = createPresenter( val presenter = createPresenter(
draftService = composerDraftService, draftService = composerDraftService,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
coroutineScope = this
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -1398,7 +1387,6 @@ class MessageComposerPresenterTest {
room = room, room = room,
draftService = composerDraftService, draftService = composerDraftService,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
coroutineScope = this
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -1428,7 +1416,7 @@ class MessageComposerPresenterTest {
val composerDraftService = FakeComposerDraftService().apply { val composerDraftService = FakeComposerDraftService().apply {
this.saveDraftLambda = saveDraftLambda this.saveDraftLambda = saveDraftLambda
} }
val presenter = createPresenter(draftService = composerDraftService, coroutineScope = this) val presenter = createPresenter(draftService = composerDraftService)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -1452,7 +1440,6 @@ class MessageComposerPresenterTest {
isRichTextEditorEnabled = false, isRichTextEditorEnabled = false,
draftService = composerDraftService, draftService = composerDraftService,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
coroutineScope = this
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present() val state = presenter.present()
@ -1528,18 +1515,17 @@ class MessageComposerPresenterTest {
return normalState return normalState
} }
private fun createPresenter( private fun TestScope.createPresenter(
coroutineScope: CoroutineScope,
room: JoinedRoom = FakeJoinedRoom( room: JoinedRoom = FakeJoinedRoom(
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
), ),
navigator: MessagesNavigator = FakeMessagesNavigator(), navigator: MessagesNavigator = FakeMessagesNavigator(),
pickerProvider: PickerProvider = this.pickerProvider, pickerProvider: PickerProvider = this@MessageComposerPresenterTest.pickerProvider,
featureFlagService: FeatureFlagService = this.featureFlagService, featureFlagService: FeatureFlagService = this@MessageComposerPresenterTest.featureFlagService,
locationService: LocationService = FakeLocationService(true), locationService: LocationService = FakeLocationService(true),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, mediaPreProcessor: MediaPreProcessor = this@MessageComposerPresenterTest.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, snackbarDispatcher: SnackbarDispatcher = this@MessageComposerPresenterTest.snackbarDispatcher,
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
permalinkParser: PermalinkParser = FakePermalinkParser(), permalinkParser: PermalinkParser = FakePermalinkParser(),
@ -1553,7 +1539,7 @@ class MessageComposerPresenterTest {
draftService: ComposerDraftService = FakeComposerDraftService(), draftService: ComposerDraftService = FakeComposerDraftService(),
) = MessageComposerPresenter( ) = MessageComposerPresenter(
navigator = navigator, navigator = navigator,
appCoroutineScope = coroutineScope, appCoroutineScope = this,
room = room, room = room,
mediaPickerProvider = pickerProvider, mediaPickerProvider = pickerProvider,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,

View file

@ -32,7 +32,6 @@ import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate
@ -155,7 +154,6 @@ class PollHistoryPresenterTest {
private fun TestScope.createPollHistoryPresenter( private fun TestScope.createPollHistoryPresenter(
room: FakeJoinedRoom = FakeJoinedRoom(), room: FakeJoinedRoom = FakeJoinedRoom(),
appCoroutineScope: CoroutineScope = this,
endPollAction: EndPollAction = FakeEndPollAction(), endPollAction: EndPollAction = FakeEndPollAction(),
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(), sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
pollHistoryItemFactory: PollHistoryItemsFactory = PollHistoryItemsFactory( pollHistoryItemFactory: PollHistoryItemsFactory = PollHistoryItemsFactory(
@ -165,7 +163,7 @@ class PollHistoryPresenterTest {
), ),
): PollHistoryPresenter { ): PollHistoryPresenter {
return PollHistoryPresenter( return PollHistoryPresenter(
appCoroutineScope = appCoroutineScope, appCoroutineScope = this,
sendPollResponseAction = sendPollResponseAction, sendPollResponseAction = sendPollResponseAction,
endPollAction = endPollAction, endPollAction = endPollAction,
pollHistoryItemFactory = pollHistoryItemFactory, pollHistoryItemFactory = pollHistoryItemFactory,

View file

@ -78,6 +78,7 @@ dependencies {
implementation(projects.features.roomlist.api) implementation(projects.features.roomlist.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.services.analytics.compose) implementation(projects.services.analytics.compose)
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
implementation(libs.datetime) implementation(libs.datetime)
implementation(libs.coil.compose) implementation(libs.coil.compose)

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import javax.inject.Inject import javax.inject.Inject
@ -37,8 +38,11 @@ class DefaultClearCacheUseCase @Inject constructor(
private val ftueService: FtueService, private val ftueService: FtueService,
private val pushService: PushService, private val pushService: PushService,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
private val activeRoomsHolder: ActiveRoomsHolder,
) : ClearCacheUseCase { ) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) { override suspend fun invoke() = withContext(coroutineDispatchers.io) {
// Active rooms should be disposed of before clearing the cache
activeRoomsHolder.clear(matrixClient.sessionId)
// Clear Matrix cache // Clear Matrix cache
matrixClient.clearCache() matrixClient.clearCache()
// Clear Coil cache // Clear Coil cache

View file

@ -15,8 +15,11 @@ import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.push.test.FakePushService import io.element.android.libraries.push.test.FakePushService
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@ -31,8 +34,10 @@ import org.robolectric.RobolectricTestRunner
class DefaultClearCacheUseCaseTest { class DefaultClearCacheUseCaseTest {
@Test @Test
fun `execute clear cache should do all the expected tasks`() = runTest { fun `execute clear cache should do all the expected tasks`() = runTest {
val activeRoomsHolder = ActiveRoomsHolder().apply { addRoom(FakeJoinedRoom()) }
val clearCacheLambda = lambdaRecorder<Unit> { } val clearCacheLambda = lambdaRecorder<Unit> { }
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
sessionId = A_SESSION_ID,
clearCacheLambda = clearCacheLambda, clearCacheLambda = clearCacheLambda,
) )
val defaultCacheService = DefaultCacheService() val defaultCacheService = DefaultCacheService()
@ -55,6 +60,7 @@ class DefaultClearCacheUseCaseTest {
ftueService = ftueService, ftueService = ftueService,
pushService = pushService, pushService = pushService,
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
activeRoomsHolder = activeRoomsHolder,
) )
defaultCacheService.clearedCacheEventFlow.test { defaultCacheService.clearedCacheEventFlow.test {
sut.invoke() sut.invoke()
@ -64,6 +70,7 @@ class DefaultClearCacheUseCaseTest {
.with(value(matrixClient.sessionId), value(false)) .with(value(matrixClient.sessionId), value(false))
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId) assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty() assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull()
} }
} }
} }

View file

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

View file

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

View file

@ -21,6 +21,7 @@ dependencies {
api(projects.features.roomcall.api) api(projects.features.roomcall.api)
implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.collections.immutable)
implementation(projects.features.call.api) implementation(projects.features.call.api)
implementation(projects.features.enterprise.api)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
@ -32,6 +33,7 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.call.test) testImplementation(projects.features.call.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest) 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.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import io.element.android.features.call.api.CurrentCall import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService 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.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
@ -23,9 +25,13 @@ import javax.inject.Inject
class RoomCallStatePresenter @Inject constructor( class RoomCallStatePresenter @Inject constructor(
private val room: JoinedRoom, private val room: JoinedRoom,
private val currentCallService: CurrentCallService, private val currentCallService: CurrentCallService,
private val enterpriseService: EnterpriseService,
) : Presenter<RoomCallState> { ) : Presenter<RoomCallState> {
@Composable @Composable
override fun present(): RoomCallState { override fun present(): RoomCallState {
val isAvailable by produceState(false) {
value = enterpriseService.isElementCallAvailable()
}
val roomInfo by room.roomInfoFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value) val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
@ -41,6 +47,7 @@ class RoomCallStatePresenter @Inject constructor(
} }
} }
val callState = when { val callState = when {
isAvailable.not() -> RoomCallState.Unavailable
roomInfo.hasRoomCall -> RoomCallState.OnGoing( roomInfo.hasRoomCall -> RoomCallState.OnGoing(
canJoinCall = canJoinCall, canJoinCall = canJoinCall,
isUserInTheCall = isUserInTheCall, 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.CurrentCall
import io.element.android.features.call.api.CurrentCallService import io.element.android.features.call.api.CurrentCallService
import io.element.android.features.call.test.FakeCurrentCallService 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.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
@ -25,12 +26,13 @@ class RoomCallStatePresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(false) }, canUserJoinCallResult = { Result.success(false) },
) )
) )
val presenter = createRoomCallStatePresenter(joinedRoom = room) val presenter = createRoomCallStatePresenter(joinedRoom = room)
presenter.test { presenter.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState).isEqualTo( assertThat(initialState).isEqualTo(
RoomCallState.StandBy( 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 @Test
fun `present - initial state - user can join call`() = runTest { fun `present - initial state - user can join call`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
) )
) )
@ -69,6 +90,7 @@ class RoomCallStatePresenterTest {
) )
val presenter = createRoomCallStatePresenter(joinedRoom = room) val presenter = createRoomCallStatePresenter(joinedRoom = room)
presenter.test { presenter.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo( assertThat(awaitItem()).isEqualTo(
RoomCallState.OnGoing( RoomCallState.OnGoing(
canJoinCall = false, canJoinCall = false,
@ -83,15 +105,15 @@ class RoomCallStatePresenterTest {
fun `present - user has joined the call on another session`() = runTest { fun `present - user has joined the call on another session`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo( givenRoomInfo(
aRoomInfo( aRoomInfo(
hasRoomCall = true, hasRoomCall = true,
activeRoomCallParticipants = listOf(sessionId), activeRoomCallParticipants = listOf(sessionId),
)
) )
) }
}
) )
val presenter = createRoomCallStatePresenter(joinedRoom = room) val presenter = createRoomCallStatePresenter(joinedRoom = room)
presenter.test { presenter.test {
@ -110,15 +132,15 @@ class RoomCallStatePresenterTest {
fun `present - user has joined the call locally`() = runTest { fun `present - user has joined the call locally`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo( givenRoomInfo(
aRoomInfo( aRoomInfo(
hasRoomCall = true, hasRoomCall = true,
activeRoomCallParticipants = listOf(sessionId), activeRoomCallParticipants = listOf(sessionId),
)
) )
) }
}
) )
val presenter = createRoomCallStatePresenter( val presenter = createRoomCallStatePresenter(
joinedRoom = room, joinedRoom = room,
@ -140,15 +162,15 @@ class RoomCallStatePresenterTest {
fun `present - user leaves the call`() = runTest { fun `present - user leaves the call`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo( givenRoomInfo(
aRoomInfo( aRoomInfo(
hasRoomCall = true, hasRoomCall = true,
activeRoomCallParticipants = listOf(sessionId), activeRoomCallParticipants = listOf(sessionId),
)
) )
) }
}
) )
val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.RoomCall(room.roomId)) val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.RoomCall(room.roomId))
val currentCallService = FakeCurrentCallService(currentCall = currentCall) val currentCallService = FakeCurrentCallService(currentCall = currentCall)
@ -203,10 +225,14 @@ class RoomCallStatePresenterTest {
private fun createRoomCallStatePresenter( private fun createRoomCallStatePresenter(
joinedRoom: JoinedRoom, joinedRoom: JoinedRoom,
currentCallService: CurrentCallService = FakeCurrentCallService(), currentCallService: CurrentCallService = FakeCurrentCallService(),
isElementCallAvailable: Boolean = true,
): RoomCallStatePresenter { ): RoomCallStatePresenter {
return RoomCallStatePresenter( return RoomCallStatePresenter(
room = joinedRoom, room = joinedRoom,
currentCallService = currentCallService, currentCallService = currentCallService,
enterpriseService = FakeEnterpriseService(
isElementCallAvailableResult = { isElementCallAvailable },
),
) )
} }
} }

View file

@ -36,6 +36,7 @@ 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.A_ROOM_TOPIC
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
@ -241,6 +242,8 @@ class RoomDetailsPresenterTest {
fun `present - initial state when user can not invite others to room`() = runTest { fun `present - initial state when user can not invite others to room`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
canInviteResult = { Result.success(false) }, canInviteResult = { Result.success(false) },
canKickResult = { Result.success(false) },
canBanResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) }, canSendStateResult = { _, _ -> Result.success(true) },
) )
@ -277,6 +280,8 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp")) else -> Result.failure(Throwable("Whelp"))
} }
}, },
canBanResult = { Result.success(false) },
canKickResult = { Result.success(false) },
canInviteResult = { Result.success(false) }, canInviteResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
) )
@ -304,6 +309,8 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp")) else -> Result.failure(Throwable("Whelp"))
} }
}, },
canKickResult = { Result.success(false) },
canBanResult = { Result.success(false) },
canInviteResult = { Result.success(false) }, canInviteResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
getUpdatedMemberResult = { userId -> getUpdatedMemberResult = { userId ->
@ -353,6 +360,8 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp")) else -> Result.failure(Throwable("Whelp"))
} }
}, },
userDisplayNameResult = { Result.success(A_USER_NAME) },
userAvatarUrlResult = { Result.success(AN_AVATAR_URL) },
canInviteResult = { Result.success(true) }, canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
getUpdatedMemberResult = { userId -> getUpdatedMemberResult = { userId ->
@ -397,6 +406,12 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp")) else -> Result.failure(Throwable("Whelp"))
} }
}, },
canKickResult = {
Result.success(false)
},
canBanResult = {
Result.success(false)
},
canInviteResult = { canInviteResult = {
Result.success(false) Result.success(false)
}, },
@ -424,6 +439,12 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp")) else -> Result.failure(Throwable("Whelp"))
} }
}, },
canBanResult = {
Result.success(false)
},
canKickResult = {
Result.success(false)
},
canInviteResult = { canInviteResult = {
Result.success(false) Result.success(false)
}, },
@ -450,6 +471,12 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp")) else -> Result.failure(Throwable("Whelp"))
} }
}, },
canKickResult = {
Result.success(false)
},
canBanResult = {
Result.success(false)
},
canInviteResult = { canInviteResult = {
Result.success(false) Result.success(false)
}, },
@ -476,6 +503,12 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp")) else -> Result.failure(Throwable("Whelp"))
} }
}, },
canKickResult = {
Result.success(false)
},
canBanResult = {
Result.success(false)
},
canInviteResult = { canInviteResult = {
Result.success(false) Result.success(false)
}, },

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
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
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
@ -289,7 +290,14 @@ class EditBaseRoomAddressPresenterTest {
val navigator = FakeSecurityAndPrivacyNavigator( val navigator = FakeSecurityAndPrivacyNavigator(
closeEditRoomAddressLambda = closeEditAddressLambda closeEditRoomAddressLambda = closeEditAddressLambda
) )
val presenter = createEditRoomAddressPresenter(navigator = navigator) val presenter = createEditRoomAddressPresenter(
navigator = navigator,
room = FakeJoinedRoom(
publishRoomAliasInRoomDirectoryResult = {
Result.failure(AN_EXCEPTION)
},
)
)
presenter.test { presenter.test {
with(awaitItem()) { with(awaitItem()) {
eventSink(EditRoomAddressEvents.RoomAddressChanged("valid")) eventSink(EditRoomAddressEvents.RoomAddressChanged("valid"))
@ -313,7 +321,13 @@ class EditBaseRoomAddressPresenterTest {
@Test @Test
fun `present - dismiss error`() = runTest { fun `present - dismiss error`() = runTest {
val presenter = createEditRoomAddressPresenter() val presenter = createEditRoomAddressPresenter(
room = FakeJoinedRoom(
publishRoomAliasInRoomDirectoryResult = {
Result.failure(AN_EXCEPTION)
},
)
)
presenter.test { presenter.test {
with(awaitItem()) { with(awaitItem()) {
eventSink(EditRoomAddressEvents.Save) eventSink(EditRoomAddressEvents.Save)

View file

@ -17,7 +17,6 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.encryption.FakeIdentityPasswordResetHandle import io.element.android.libraries.matrix.test.encryption.FakeIdentityPasswordResetHandle
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
@ -132,11 +131,10 @@ class ResetIdentityFlowManagerTest {
private fun TestScope.createFlowManager( private fun TestScope.createFlowManager(
encryptionService: FakeEncryptionService = FakeEncryptionService(), encryptionService: FakeEncryptionService = FakeEncryptionService(),
client: FakeMatrixClient = FakeMatrixClient(encryptionService = encryptionService), client: FakeMatrixClient = FakeMatrixClient(encryptionService = encryptionService),
sessionCoroutineScope: CoroutineScope = this,
sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
) = ResetIdentityFlowManager( ) = ResetIdentityFlowManager(
matrixClient = client, matrixClient = client,
sessionCoroutineScope = sessionCoroutineScope, sessionCoroutineScope = this,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
) )
} }

View file

@ -37,6 +37,7 @@ dependencies {
implementation(projects.libraries.roomselect.api) implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.services.appnavstate.api)
api(libs.statemachine) api(libs.statemachine)
api(projects.features.share.api) api(projects.features.share.api)

View file

@ -20,9 +20,11 @@ import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.MatrixClient 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.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -33,6 +35,7 @@ class SharePresenter @AssistedInject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val activeRoomsHolder: ActiveRoomsHolder,
) : Presenter<ShareState> { ) : Presenter<ShareState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -59,6 +62,12 @@ class SharePresenter @AssistedInject constructor(
) )
} }
private suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? {
return activeRoomsHolder.getActiveRoom(matrixClient.sessionId)
?.takeIf { it.roomId == roomId }
?: matrixClient.getJoinedRoom(roomId)
}
private fun CoroutineScope.share( private fun CoroutineScope.share(
intent: Intent, intent: Intent,
roomIds: List<RoomId>, roomIds: List<RoomId>,
@ -72,7 +81,7 @@ class SharePresenter @AssistedInject constructor(
} else { } else {
roomIds roomIds
.map { roomId -> .map { roomId ->
val room = matrixClient.getJoinedRoom(roomId) ?: return@map false val room = getJoinedRoom(roomId) ?: return@map false
val mediaSender = MediaSender( val mediaSender = MediaSender(
preProcessor = mediaPreProcessor, preProcessor = mediaPreProcessor,
room = room, room = room,
@ -86,7 +95,11 @@ class SharePresenter @AssistedInject constructor(
).isSuccess ).isSuccess
} }
.all { it } .all { it }
.also { room.destroy() } .also {
if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) {
room.destroy()
}
}
} }
.all { it } .all { it }
} }
@ -94,7 +107,7 @@ class SharePresenter @AssistedInject constructor(
onPlainText = { text -> onPlainText = { text ->
roomIds roomIds
.map { roomId -> .map { roomId ->
matrixClient.getJoinedRoom(roomId)?.liveTimeline?.sendMessage( getJoinedRoom(roomId)?.liveTimeline?.sendMessage(
body = text, body = text,
htmlBody = null, htmlBody = null,
intentionalMentions = emptyList(), intentionalMentions = emptyList(),

View file

@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -163,7 +164,8 @@ class SharePresenterTest {
intent: Intent = Intent(), intent: Intent = Intent(),
shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(), shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(),
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor() mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
): SharePresenter { ): SharePresenter {
return SharePresenter( return SharePresenter(
intent = intent, intent = intent,
@ -171,7 +173,8 @@ class SharePresenterTest {
shareIntentHandler = shareIntentHandler, shareIntentHandler = shareIntentHandler,
matrixClient = matrixClient, matrixClient = matrixClient,
mediaPreProcessor = mediaPreProcessor, mediaPreProcessor = mediaPreProcessor,
InMemorySessionPreferencesStore(), sessionPreferencesStore = InMemorySessionPreferencesStore(),
activeRoomsHolder = activeRoomsHolder,
) )
} }
} }

View file

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

View file

@ -21,6 +21,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.createroom.api.StartDMAction 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.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileState.ConfirmationDialog import io.element.android.features.userprofile.api.UserProfileState.ConfirmationDialog
@ -44,6 +45,7 @@ class UserProfilePresenter @AssistedInject constructor(
@Assisted private val userId: UserId, @Assisted private val userId: UserId,
private val client: MatrixClient, private val client: MatrixClient,
private val startDMAction: StartDMAction, private val startDMAction: StartDMAction,
private val enterpriseService: EnterpriseService,
) : Presenter<UserProfileState> { ) : Presenter<UserProfileState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -59,11 +61,21 @@ class UserProfilePresenter @AssistedInject constructor(
@Composable @Composable
private fun getCanCall(roomId: RoomId?): State<Boolean> { private fun getCanCall(roomId: RoomId?): State<Boolean> {
return produceState(initialValue = false, roomId) { val isElementCallAvailable by produceState(initialValue = false, roomId) {
value = if (client.isMe(userId)) { value = enterpriseService.isElementCallAvailable()
false }
} else {
roomId?.let { client.getRoom(it)?.canUserJoinCall(client.sessionId)?.getOrNull() == true }.orFalse() 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.ConfirmingStartDmWithMatrixUser
import io.element.android.features.createroom.api.StartDMAction import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.createroom.test.FakeStartDMAction 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.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState 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.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.WarmUpRule 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.any
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value 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`() { fun `present - canCall is true when all the conditions are met`() {
testCanCall( testCanCall(
expectedResult = true, 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( private fun testCanCall(
isElementCallAvailable: Boolean = true,
canUserJoinCallResult: Result<Boolean> = Result.success(true), canUserJoinCallResult: Result<Boolean> = Result.success(true),
dmRoom: RoomId? = A_ROOM_ID, dmRoom: RoomId? = A_ROOM_ID,
canFindRoom: Boolean = true, canFindRoom: Boolean = true,
expectedResult: Boolean, expectedResult: Boolean,
skipItems: Int = 1,
checkThatRoomIsDestroyed: Boolean = false,
) = runTest { ) = runTest {
val room = FakeBaseRoom( val room = FakeBaseRoom(
canUserJoinCallResult = { canUserJoinCallResult }, canUserJoinCallResult = { canUserJoinCallResult },
@ -134,11 +147,15 @@ class UserProfilePresenterTest {
val presenter = createUserProfilePresenter( val presenter = createUserProfilePresenter(
userId = A_USER_ID_2, userId = A_USER_ID_2,
client = client, client = client,
isElementCallAvailable = isElementCallAvailable,
) )
presenter.test { presenter.test {
val initialState = awaitLastSequentialItem() val initialState = awaitFirstItem(skipItems)
assertThat(initialState.canCall).isEqualTo(expectedResult) assertThat(initialState.canCall).isEqualTo(expectedResult)
} }
if (checkThatRoomIsDestroyed) {
room.assertDestroyed()
}
} }
@Test @Test
@ -202,7 +219,7 @@ class UserProfilePresenterTest {
) )
val presenter = createUserProfilePresenter(client = matrixClient) val presenter = createUserProfilePresenter(client = matrixClient)
presenter.test { presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem(count = 2)
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false)) initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue() assertThat(awaitItem().isBlocked.isLoading()).isTrue()
val errorState = awaitItem() val errorState = awaitItem()
@ -220,7 +237,7 @@ class UserProfilePresenterTest {
) )
val presenter = createUserProfilePresenter(client = matrixClient) val presenter = createUserProfilePresenter(client = matrixClient)
presenter.test { presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem(count = 2)
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false)) initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue() assertThat(awaitItem().isBlocked.isLoading()).isTrue()
val errorState = awaitItem() val errorState = awaitItem()
@ -363,8 +380,8 @@ class UserProfilePresenterTest {
} }
} }
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(count: Int = 1): T {
skipItems(1) skipItems(count)
return awaitItem() return awaitItem()
} }
@ -387,12 +404,16 @@ class UserProfilePresenterTest {
private fun createUserProfilePresenter( private fun createUserProfilePresenter(
client: MatrixClient = createFakeMatrixClient(), client: MatrixClient = createFakeMatrixClient(),
userId: UserId = UserId("@alice:server.org"), userId: UserId = UserId("@alice:server.org"),
startDMAction: StartDMAction = FakeStartDMAction() startDMAction: StartDMAction = FakeStartDMAction(),
isElementCallAvailable: Boolean = true,
): UserProfilePresenter { ): UserProfilePresenter {
return UserProfilePresenter( return UserProfilePresenter(
userId = userId, userId = userId,
client = client, client = client,
startDMAction = startDMAction, startDMAction = startDMAction,
enterpriseService = FakeEnterpriseService(
isElementCallAvailableResult = { isElementCallAvailable },
),
) )
} }
} }

View file

@ -16,7 +16,7 @@ constraintlayout = "2.2.1"
constraintlayout_compose = "1.1.1" constraintlayout_compose = "1.1.1"
lifecycle = "2.9.0" lifecycle = "2.9.0"
activity = "1.10.1" activity = "1.10.1"
media3 = "1.6.1" media3 = "1.7.1"
camera = "1.4.2" camera = "1.4.2"
# Compose # Compose
@ -40,7 +40,7 @@ serialization_json = "1.8.1"
coil = "3.1.0" coil = "3.1.0"
showkase = "1.0.3" showkase = "1.0.3"
appyx = "1.7.0" appyx = "1.7.0"
sqldelight = "2.0.2" sqldelight = "2.1.0"
wysiwyg = "2.38.3" wysiwyg = "2.38.3"
telephoto = "0.15.1" telephoto = "0.15.1"
@ -72,7 +72,7 @@ kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", ve
ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
gms_google_services = "com.google.gms:google-services:4.4.2" gms_google_services = "com.google.gms:google-services:4.4.2"
# https://firebase.google.com/docs/android/setup#available-libraries # https://firebase.google.com/docs/android/setup#available-libraries
google_firebase_bom = "com.google.firebase:firebase-bom:33.13.0" google_firebase_bom = "com.google.firebase:firebase-bom:33.14.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } 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" } 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" } ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
@ -169,7 +169,7 @@ jsoup = "org.jsoup:jsoup:1.20.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.1.0" molecule-runtime = "app.cash.molecule:molecule-runtime:2.1.0"
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.5.21" matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.5.26"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -177,7 +177,7 @@ sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
sqlite = "androidx.sqlite:sqlite-ktx:2.5.1" sqlite = "androidx.sqlite:sqlite-ktx:2.5.1"
unifiedpush = "com.github.UnifiedPush:android-connector:2.4.0" unifiedpush = "org.unifiedpush.android:connector:3.0.9"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.2" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.2"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
@ -190,7 +190,7 @@ opusencoder = "io.element.android:opusencoder:1.1.0"
zxing_cpp = "io.github.zxing-cpp:android:2.3.0" zxing_cpp = "io.github.zxing-cpp:android:2.3.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.15.0" posthog = "com.posthog:posthog-android:3.15.1"
sentry = "io.sentry:sentry-android:8.12.0" sentry = "io.sentry:sentry-android:8.12.0"
# main branch can be tested replacing the version with main-SNAPSHOT # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"
@ -232,7 +232,7 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" } anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" }
detekt = "io.gitlab.arturbosch.detekt:1.23.8" detekt = "io.gitlab.arturbosch.detekt:1.23.8"
ktlint = "org.jlleitschuh.gradle.ktlint:12.2.0" ktlint = "org.jlleitschuh.gradle.ktlint:12.3.0"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12" dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
dependencycheck = "org.owasp.dependencycheck:12.1.1" dependencycheck = "org.owasp.dependencycheck:12.1.1"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }

View file

@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=61ad310d3c7d3e5da131b76bbf22b5a4c0786e9d892dae8c1658d4b484de3caa distributionSha256Sum=845952a9d6afa783db70bb3b0effaae45ae5542ca2bb7929619e8af49cb634cf
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View file

@ -9,12 +9,14 @@ package io.element.android.libraries.matrix.api.notification
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
data class NotificationData( data class NotificationData(
val sessionId: SessionId,
val eventId: EventId, val eventId: EventId,
val threadId: ThreadId?, val threadId: ThreadId?,
val roomId: RoomId, val roomId: RoomId,

View file

@ -11,5 +11,5 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
interface NotificationService { interface NotificationService {
suspend fun getNotification(roomId: RoomId, eventId: EventId): Result<NotificationData?> suspend fun getNotifications(ids: Map<RoomId, List<EventId>>): Result<Map<EventId, NotificationData>>
} }

View file

@ -120,10 +120,6 @@ class RustClientSessionDelegate(
} }
} }
override fun didRefreshTokens() {
// This is done in `saveSessionInKeychain(Session)` instead.
}
override fun retrieveSessionFromKeychain(userId: String): Session { override fun retrieveSessionFromKeychain(userId: String): Session {
// This should never be called, as it's only used for multi-process setups // This should never be called, as it's only used for multi-process setups
error("retrieveSessionFromKeychain should never be called for Android") error("retrieveSessionFromKeychain should never be called for Android")

View file

@ -152,7 +152,7 @@ class RustMatrixClient(
) )
private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(innerSyncService) private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(innerSyncService)
private val innerNotificationClient = runBlocking { innerClient.notificationClient(notificationProcessSetup) } private val innerNotificationClient = runBlocking { innerClient.notificationClient(notificationProcessSetup) }
private val notificationService = RustNotificationService(innerNotificationClient, dispatchers, clock) private val notificationService = RustNotificationService(sessionId, innerNotificationClient, dispatchers, clock)
private val notificationSettingsService = RustNotificationSettingsService(innerClient, sessionCoroutineScope, dispatchers) private val notificationSettingsService = RustNotificationSettingsService(innerClient, sessionCoroutineScope, dispatchers)
private val encryptionService = RustEncryptionService( private val encryptionService = RustEncryptionService(
client = innerClient, client = innerClient,
@ -189,7 +189,6 @@ class RustMatrixClient(
private val roomMembershipObserver = RoomMembershipObserver() private val roomMembershipObserver = RoomMembershipObserver()
private val roomFactory = RustRoomFactory( private val roomFactory = RustRoomFactory(
innerClient = innerClient,
roomListService = roomListService, roomListService = roomListService,
innerRoomListService = innerRoomListService, innerRoomListService = innerRoomListService,
sessionId = sessionId, sessionId = sessionId,

View file

@ -72,8 +72,9 @@ class RustMatrixClientFactory @Inject constructor(
suspend fun create(client: Client): RustMatrixClient { suspend fun create(client: Client): RustMatrixClient {
val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens() val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
client.setUtdDelegate(UtdTracker(analyticsService))
val syncService = client.syncService() val syncService = client.syncService()
.withUtdHook(UtdTracker(analyticsService))
.withOfflineMode() .withOfflineMode()
.finish() .finish()

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationData
@ -25,6 +26,7 @@ class NotificationMapper(
private val notificationContentMapper = NotificationContentMapper() private val notificationContentMapper = NotificationContentMapper()
fun map( fun map(
sessionId: SessionId,
eventId: EventId, eventId: EventId,
roomId: RoomId, roomId: RoomId,
notificationItem: NotificationItem notificationItem: NotificationItem
@ -35,6 +37,7 @@ class NotificationMapper(
activeMembersCount = item.roomInfo.joinedMembersCount.toInt(), activeMembersCount = item.roomInfo.joinedMembersCount.toInt(),
) )
NotificationData( NotificationData(
sessionId = sessionId,
eventId = eventId, eventId = eventId,
// FIXME once the `NotificationItem` in the SDK returns the thread id // FIXME once the `NotificationItem` in the SDK returns the thread id
threadId = null, threadId = null,

View file

@ -10,28 +10,45 @@ package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.NotificationItemsRequest
import timber.log.Timber
class RustNotificationService( class RustNotificationService(
private val sessionId: SessionId,
private val notificationClient: NotificationClient, private val notificationClient: NotificationClient,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
clock: SystemClock, clock: SystemClock,
) : NotificationService { ) : NotificationService {
private val notificationMapper: NotificationMapper = NotificationMapper(clock) private val notificationMapper: NotificationMapper = NotificationMapper(clock)
override suspend fun getNotification( override suspend fun getNotifications(
roomId: RoomId, ids: Map<RoomId, List<EventId>>
eventId: EventId, ): Result<Map<EventId, NotificationData>> = withContext(dispatchers.io) {
): Result<NotificationData?> = withContext(dispatchers.io) {
runCatching { runCatching {
val item = notificationClient.getNotification(roomId.value, eventId.value) val requests = ids.map { (roomId, eventIds) ->
item?.use { NotificationItemsRequest(
notificationMapper.map(eventId, roomId, it) roomId = roomId.value,
eventIds = eventIds.map { it.value }
)
}
val items = notificationClient.getNotifications(requests)
buildMap {
val eventIds = requests.flatMap { it.eventIds }
for (eventId in eventIds) {
val item = items[eventId]
if (item != null) {
val roomId = RoomId(requests.find { it.eventIds.contains(eventId) }?.roomId!!)
put(EventId(eventId), notificationMapper.map(sessionId, EventId(eventId), roomId, item))
} else {
Timber.e("Could not retrieve event for notification with $eventId")
}
}
} }
} }
} }

View file

@ -8,7 +8,9 @@
package io.element.android.libraries.matrix.impl.proxy package io.element.android.libraries.matrix.impl.proxy
import android.content.Context import android.content.Context
import android.net.ConnectivityManager
import android.provider.Settings import android.provider.Settings
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
@ -32,6 +34,13 @@ class DefaultProxyProvider @Inject constructor(
private val context: Context private val context: Context
) : ProxyProvider { ) : ProxyProvider {
override fun provides(): String? { override fun provides(): String? {
val defaultProxy = context.getSystemService<ConnectivityManager>()?.defaultProxy
if (defaultProxy == null) {
// Note: can be tested by running:
// adb shell settings put global http_proxy :0
Timber.d("No default proxy")
return null
}
return Settings.Global.getString(context.contentResolver, Settings.Global.HTTP_PROXY) return Settings.Global.getString(context.contentResolver, Settings.Global.HTTP_PROXY)
?.also { ?.also {
Timber.d("Using global proxy") Timber.d("Using global proxy")

View file

@ -212,6 +212,7 @@ class JoinedRustRoom(
internalIdPrefix = internalIdPrefix, internalIdPrefix = internalIdPrefix,
dateDividerMode = dateDividerMode, dateDividerMode = dateDividerMode,
trackReadReceipts = trackReadReceipts, trackReadReceipts = trackReadReceipts,
reportUtds = true,
) )
).let { innerTimeline -> ).let { innerTimeline ->
val mode = when (createTimelineParams) { val mode = when (createTimelineParams) {
@ -416,7 +417,6 @@ class JoinedRustRoom(
RustWidgetDriver( RustWidgetDriver(
widgetSettings = widgetSettings, widgetSettings = widgetSettings,
room = innerRoom, room = innerRoom,
joinedRustRoom = this,
widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider { widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider {
override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities { override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities {
return getElementCallRequiredPermissions(sessionId.value, baseRoom.deviceId.value) return getElementCallRequiredPermissions(sessionId.value, baseRoom.deviceId.value)

View file

@ -11,7 +11,6 @@ import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.ForwardEventException import io.element.android.libraries.matrix.api.room.ForwardEventException
import io.element.android.libraries.matrix.impl.roomlist.fullRoomWithTimeline
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.libraries.matrix.impl.timeline.runWithTimelineListenerRegistered import io.element.android.libraries.matrix.impl.timeline.runWithTimelineListenerRegistered
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -49,10 +48,7 @@ class RoomContentForwarder(
val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content
?: throw ForwardEventException(toRoomIds) ?: throw ForwardEventException(toRoomIds)
val targetSlidingSyncRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) } val targetRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val targetRooms = targetSlidingSyncRooms.map { slidingSyncRoom ->
slidingSyncRoom.use { it.fullRoomWithTimeline(null) }
}
val failedForwardingTo = mutableSetOf<RoomId>() val failedForwardingTo = mutableSetOf<RoomId>()
targetRooms.parallelMap { room -> targetRooms.parallelMap { room ->
room.use { targetRoom -> room.use { targetRoom ->

View file

@ -20,7 +20,6 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.awaitLoaded import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper
import io.element.android.libraries.matrix.impl.roomlist.fullRoomWithTimeline
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -28,10 +27,12 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.Membership import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListItem import org.matrix.rustcomponents.sdk.TimelineConfiguration
import org.matrix.rustcomponents.sdk.TimelineFilter
import org.matrix.rustcomponents.sdk.TimelineFocus
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService
@ -39,7 +40,6 @@ import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService
class RustRoomFactory( class RustRoomFactory(
private val sessionId: SessionId, private val sessionId: SessionId,
private val deviceId: DeviceId, private val deviceId: DeviceId,
private val innerClient: Client,
private val notificationSettingsService: NotificationSettingsService, private val notificationSettingsService: NotificationSettingsService,
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
@ -79,16 +79,11 @@ class RustRoomFactory(
Timber.d("Room factory is destroyed, returning null for $roomId") Timber.d("Room factory is destroyed, returning null for $roomId")
return@withContext null return@withContext null
} }
val roomListItem = awaitRoomListItem(roomId) ?: return@withContext null val room = awaitRoomInRoomList(roomId) ?: return@withContext null
getBaseRoom(roomListItem) getBaseRoom(room)
} }
} }
private suspend fun getBaseRoom(roomListItem: RoomListItem): RustBaseRoom? {
val sdkRoom = innerClient.getRoom(roomListItem.id()) ?: return null
return getBaseRoom(sdkRoom)
}
private suspend fun getBaseRoom(sdkRoom: Room): RustBaseRoom { private suspend fun getBaseRoom(sdkRoom: Room): RustBaseRoom {
val initialRoomInfo = sdkRoom.roomInfo() val initialRoomInfo = sdkRoom.roomInfo()
return RustBaseRoom( return RustBaseRoom(
@ -110,18 +105,27 @@ class RustRoomFactory(
Timber.d("Room factory is destroyed, returning null for $roomId") Timber.d("Room factory is destroyed, returning null for $roomId")
return@withContext null return@withContext null
} }
val roomListItem = awaitRoomListItem(roomId) ?: return@withContext null val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withContext null
if (roomListItem.membership() == Membership.JOINED) { if (sdkRoom.membership() == Membership.JOINED) {
// Init the live timeline in the SDK from the RoomListItem // Init the live timeline in the SDK from the Room
val sdkRoom = roomListItem.fullRoomWithTimeline(eventFilters) val timeline = sdkRoom.timelineWithConfiguration(
TimelineConfiguration(
focus = TimelineFocus.Live,
filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All,
internalIdPrefix = "live",
dateDividerMode = DateDividerMode.DAILY,
trackReadReceipts = true,
reportUtds = true,
)
)
GetRoomResult.Joined( GetRoomResult.Joined(
JoinedRustRoom( JoinedRustRoom(
baseRoom = getBaseRoom(sdkRoom), baseRoom = getBaseRoom(sdkRoom),
notificationSettingsService = notificationSettingsService, notificationSettingsService = notificationSettingsService,
roomContentForwarder = roomContentForwarder, roomContentForwarder = roomContentForwarder,
liveInnerTimeline = sdkRoom.timeline(), liveInnerTimeline = timeline,
coroutineDispatchers = dispatchers, coroutineDispatchers = dispatchers,
systemClock = systemClock, systemClock = systemClock,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
@ -129,7 +133,7 @@ class RustRoomFactory(
) )
} else { } else {
val preview = try { val preview = try {
roomListItem.previewRoom(via = emptyList()) sdkRoom.previewRoom(via = emptyList())
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to get room preview for $roomId") Timber.e(e, "Failed to get room preview for $roomId")
return@withContext null return@withContext null
@ -138,7 +142,7 @@ class RustRoomFactory(
GetRoomResult.NotJoined( GetRoomResult.NotJoined(
NotJoinedRustRoom( NotJoinedRustRoom(
sessionId = sessionId, sessionId = sessionId,
localRoom = getBaseRoom(roomListItem), localRoom = getBaseRoom(sdkRoom),
previewInfo = RoomPreviewInfoMapper.map(preview.info()), previewInfo = RoomPreviewInfoMapper.map(preview.info()),
) )
) )
@ -147,22 +151,22 @@ class RustRoomFactory(
} }
/** /**
* Get the Rust room list item for a room, retrying after the room list is loaded if necessary. * Get the Rust room for a room, retrying after the room list is loaded if necessary.
*/ */
private suspend fun awaitRoomListItem(roomId: RoomId): RoomListItem? { private suspend fun awaitRoomInRoomList(roomId: RoomId): Room? {
var roomListItem = innerRoomListService.roomOrNull(roomId.value) var sdkRoom = innerRoomListService.roomOrNull(roomId.value)
if (roomListItem == null) { if (sdkRoom == null) {
// ... otherwise, lets wait for the SS to load all rooms and check again. // ... otherwise, lets wait for the SS to load all rooms and check again.
roomListService.allRooms.awaitLoaded() roomListService.allRooms.awaitLoaded()
roomListItem = innerRoomListService.roomOrNull(roomId.value) sdkRoom = innerRoomListService.roomOrNull(roomId.value)
} }
if (roomListItem == null) { if (sdkRoom == null) {
Timber.d("Room not found for $roomId") Timber.d("Room not found for $roomId")
return null return null
} }
return roomListItem return sdkRoom
} }
} }

View file

@ -19,11 +19,11 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
import org.matrix.rustcomponents.sdk.RoomListEntriesListener import org.matrix.rustcomponents.sdk.RoomListEntriesListener
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
import org.matrix.rustcomponents.sdk.RoomListInterface import org.matrix.rustcomponents.sdk.RoomListInterface
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomListLoadingState import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener
import org.matrix.rustcomponents.sdk.RoomListServiceInterface import org.matrix.rustcomponents.sdk.RoomListServiceInterface
@ -114,7 +114,7 @@ internal fun RoomListServiceInterface.syncIndicator(): Flow<RoomListServiceSyncI
) )
}.buffer(Channel.UNLIMITED) }.buffer(Channel.UNLIMITED)
internal fun RoomListServiceInterface.roomOrNull(roomId: String): RoomListItem? { internal fun RoomListServiceInterface.roomOrNull(roomId: String): Room? {
return tryOrNull( return tryOrNull(
onError = { Timber.e(it, "Failed finding room with id=$roomId.") } onError = { Timber.e(it, "Failed finding room with id=$roomId.") }
) { ) {

View file

@ -1,20 +0,0 @@
/*
* Copyright 2024 New Vector 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.roomlist
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter
/** Returns a `Room` with an initialized timeline using the given [filter]. */
suspend fun RoomListItem.fullRoomWithTimeline(filter: TimelineEventTypeFilter? = null): Room {
if (!isTimelineInitialized()) {
initTimeline(filter, "live")
}
return fullRoom()
}

View file

@ -10,16 +10,16 @@ package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.impl.room.RoomInfoMapper import io.element.android.libraries.matrix.impl.room.RoomInfoMapper
import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory
import org.matrix.rustcomponents.sdk.RoomListItem import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
class RoomSummaryFactory( class RoomSummaryFactory(
private val roomMessageFactory: RoomMessageFactory = RoomMessageFactory(), private val roomMessageFactory: RoomMessageFactory = RoomMessageFactory(),
private val roomInfoMapper: RoomInfoMapper = RoomInfoMapper(), private val roomInfoMapper: RoomInfoMapper = RoomInfoMapper(),
) { ) {
suspend fun create(roomListItem: RoomListItem): RoomSummary { suspend fun create(room: Room): RoomSummary {
val roomInfo = roomListItem.roomInfo().let(roomInfoMapper::map) val roomInfo = room.roomInfo().let(roomInfoMapper::map)
val latestRoomMessage = roomListItem.latestEvent().use { event -> val latestRoomMessage = room.latestEvent().use { event ->
roomMessageFactory.create(event) roomMessageFactory.create(event)
} }
return RoomSummary( return RoomSummary(

View file

@ -12,8 +12,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomListServiceInterface import org.matrix.rustcomponents.sdk.RoomListServiceInterface
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
@ -95,20 +95,16 @@ class RoomSummaryListProcessor(
} }
} }
private suspend fun buildSummaryForRoomListEntry(entry: RoomListItem): RoomSummary { private suspend fun buildSummaryForRoomListEntry(entry: Room): RoomSummary {
return buildRoomSummaryForRoomListItem(entry) return entry.use { roomSummaryDetailsFactory.create(room = it) }
} }
private suspend fun buildRoomSummaryForIdentifier(identifier: String): RoomSummary? { private suspend fun buildRoomSummaryForIdentifier(identifier: String): RoomSummary? {
return roomListService.roomOrNull(identifier)?.use { roomListItem -> return roomListService.roomOrNull(identifier)?.let { room ->
buildRoomSummaryForRoomListItem(roomListItem) buildSummaryForRoomListEntry(room)
} }
} }
private suspend fun buildRoomSummaryForRoomListItem(roomListItem: RoomListItem): RoomSummary {
return roomSummaryDetailsFactory.create(roomListItem = roomListItem)
}
private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) = withContext(coroutineContext) { private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) = withContext(coroutineContext) {
mutex.withLock { mutex.withLock {
val current = roomSummaries.replayCache.lastOrNull() val current = roomSummaries.replayCache.lastOrNull()

View file

@ -53,4 +53,5 @@ fun TracingConfiguration.map(): org.matrix.rustcomponents.sdk.TracingConfigurati
extraTargets = extraTargets, extraTargets = extraTargets,
traceLogPacks = traceLogPacks.map(), traceLogPacks = traceLogPacks.map(),
writeToFiles = writesToFilesConfiguration.toTracingFileConfiguration(), writeToFiles = writesToFilesConfiguration.toTracingFileConfiguration(),
sentryDsn = null,
) )

View file

@ -9,7 +9,6 @@ package io.element.android.libraries.matrix.impl.widget
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.impl.room.JoinedRustRoom
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -25,7 +24,6 @@ import kotlin.coroutines.coroutineContext
class RustWidgetDriver( class RustWidgetDriver(
widgetSettings: MatrixWidgetSettings, widgetSettings: MatrixWidgetSettings,
private val room: Room, private val room: Room,
private val joinedRustRoom: JoinedRustRoom,
private val widgetCapabilitiesProvider: WidgetCapabilitiesProvider, private val widgetCapabilitiesProvider: WidgetCapabilitiesProvider,
) : MatrixWidgetDriver { ) : MatrixWidgetDriver {
// It's important to have extra capacity here to make sure we don't drop any messages // It's important to have extra capacity here to make sure we don't drop any messages
@ -71,6 +69,5 @@ class RustWidgetDriver(
override fun close() { override fun close() {
receiveMessageJob?.cancel() receiveMessageJob?.cancel()
driverAndHandle.driver.close() driverAndHandle.driver.close()
joinedRustRoom.destroy()
} }
} }

View file

@ -25,6 +25,7 @@ import org.matrix.rustcomponents.sdk.RoomDirectorySearch
import org.matrix.rustcomponents.sdk.Session import org.matrix.rustcomponents.sdk.Session
import org.matrix.rustcomponents.sdk.SyncServiceBuilder import org.matrix.rustcomponents.sdk.SyncServiceBuilder
import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate
class FakeRustClient( class FakeRustClient(
private val userId: String = A_USER_ID.value, private val userId: String = A_USER_ID.value,
@ -34,6 +35,7 @@ class FakeRustClient(
private val encryption: Encryption = FakeRustEncryption(), private val encryption: Encryption = FakeRustEncryption(),
private val session: Session = aRustSession(), private val session: Session = aRustSession(),
private val clearCachesResult: () -> Unit = { lambdaError() }, private val clearCachesResult: () -> Unit = { lambdaError() },
private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() },
private val closeResult: () -> Unit = {}, private val closeResult: () -> Unit = {},
) : Client(NoPointer) { ) : Client(NoPointer) {
override fun userId(): String = userId override fun userId(): String = userId
@ -58,5 +60,6 @@ class FakeRustClient(
override suspend fun deletePusher(identifiers: PusherIdentifiers) = Unit override suspend fun deletePusher(identifiers: PusherIdentifiers) = Unit
override suspend fun clearCaches() = simulateLongTask { clearCachesResult() } override suspend fun clearCaches() = simulateLongTask { clearCachesResult() }
override suspend fun setUtdDelegate(utdDelegate: UnableToDecryptDelegate) = withUtdHook(utdDelegate)
override fun close() = closeResult() override fun close() = closeResult()
} }

View file

@ -47,6 +47,6 @@ class FakeRustClientBuilder : ClientBuilder(NoPointer) {
} }
override suspend fun build(): Client { override suspend fun build(): Client {
return FakeRustClient() return FakeRustClient(withUtdHook = {})
} }
} }

View file

@ -7,15 +7,15 @@
package io.element.android.libraries.matrix.impl.fixtures.fakes package io.element.android.libraries.matrix.impl.fixtures.fakes
import io.element.android.tests.testutils.simulateLongTask
import org.matrix.rustcomponents.sdk.NoPointer import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationItem import org.matrix.rustcomponents.sdk.NotificationItem
import org.matrix.rustcomponents.sdk.NotificationItemsRequest
class FakeRustNotificationClient( class FakeRustNotificationClient(
var notificationItemResult: NotificationItem? = null var notificationItemResult: Map<String, NotificationItem> = emptyMap(),
) : NotificationClient(NoPointer) { ) : NotificationClient(NoPointer) {
override suspend fun getNotification(roomId: String, eventId: String): NotificationItem? = simulateLongTask { override suspend fun getNotifications(requests: List<NotificationItemsRequest>): Map<String, NotificationItem> {
notificationItemResult return notificationItemResult
} }
} }

View file

@ -8,10 +8,13 @@
package io.element.android.libraries.matrix.impl.fixtures.fakes package io.element.android.libraries.matrix.impl.fixtures.fakes
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomInfo
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import org.matrix.rustcomponents.sdk.EventTimelineItem
import org.matrix.rustcomponents.sdk.NoPointer import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomMembersIterator import org.matrix.rustcomponents.sdk.RoomMembersIterator
class FakeRustRoom( class FakeRustRoom(
@ -19,6 +22,8 @@ class FakeRustRoom(
private val getMembers: () -> RoomMembersIterator = { lambdaError() }, private val getMembers: () -> RoomMembersIterator = { lambdaError() },
private val getMembersNoSync: () -> RoomMembersIterator = { lambdaError() }, private val getMembersNoSync: () -> RoomMembersIterator = { lambdaError() },
private val leaveLambda: () -> Unit = { lambdaError() }, private val leaveLambda: () -> Unit = { lambdaError() },
private val latestEventLambda: () -> EventTimelineItem? = { lambdaError() },
private val roomInfo: RoomInfo = aRustRoomInfo(id = roomId.value),
) : Room(NoPointer) { ) : Room(NoPointer) {
override fun id(): String { override fun id(): String {
return roomId.value return roomId.value
@ -36,6 +41,14 @@ class FakeRustRoom(
leaveLambda() leaveLambda()
} }
override suspend fun roomInfo(): RoomInfo {
return roomInfo
}
override suspend fun latestEvent(): EventTimelineItem? {
return latestEventLambda()
}
override fun close() { override fun close() {
// No-op // No-op
} }

View file

@ -1,33 +0,0 @@
/*
* Copyright 2024 New Vector 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.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomInfo
import org.matrix.rustcomponents.sdk.EventTimelineItem
import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomListItem
class FakeRustRoomListItem(
private val roomId: RoomId,
private val roomInfo: RoomInfo = aRustRoomInfo(id = roomId.value),
private val latestEvent: EventTimelineItem? = null,
) : RoomListItem(NoPointer) {
override fun id(): String {
return roomId.value
}
override suspend fun roomInfo(): RoomInfo {
return roomInfo
}
override suspend fun latestEvent(): EventTimelineItem? {
return latestEvent
}
}

View file

@ -10,10 +10,8 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes
import org.matrix.rustcomponents.sdk.NoPointer import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.SyncService import org.matrix.rustcomponents.sdk.SyncService
import org.matrix.rustcomponents.sdk.SyncServiceBuilder import org.matrix.rustcomponents.sdk.SyncServiceBuilder
import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate
class FakeRustSyncServiceBuilder : SyncServiceBuilder(NoPointer) { class FakeRustSyncServiceBuilder : SyncServiceBuilder(NoPointer) {
override suspend fun withUtdHook(delegate: UnableToDecryptDelegate): SyncServiceBuilder = this
override fun withOfflineMode(): SyncServiceBuilder = this override fun withOfflineMode(): SyncServiceBuilder = this
override suspend fun finish(): SyncService = FakeRustSyncService() override suspend fun finish(): SyncService = FakeRustSyncService()
} }

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustNotificat
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
@ -28,12 +29,12 @@ class RustNotificationServiceTest {
@Test @Test
fun test() = runTest { fun test() = runTest {
val notificationClient = FakeRustNotificationClient( val notificationClient = FakeRustNotificationClient(
notificationItemResult = aRustNotificationItem(), notificationItemResult = mapOf(AN_EVENT_ID.value to aRustNotificationItem()),
) )
val sut = createRustNotificationService( val sut = createRustNotificationService(
notificationClient = notificationClient, notificationClient = notificationClient,
) )
val result = sut.getNotification(A_ROOM_ID, AN_EVENT_ID).getOrThrow()!! val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!
assertThat(result.isEncrypted).isTrue() assertThat(result.isEncrypted).isTrue()
assertThat(result.content).isEqualTo( assertThat(result.content).isEqualTo(
NotificationContent.MessageLike.RoomMessage( NotificationContent.MessageLike.RoomMessage(
@ -51,6 +52,7 @@ class RustNotificationServiceTest {
clock: SystemClock = FakeSystemClock(), clock: SystemClock = FakeSystemClock(),
) = ) =
RustNotificationService( RustNotificationService(
sessionId = A_SESSION_ID,
notificationClient = notificationClient, notificationClient = notificationClient,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
clock = clock, clock = clock,

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.room package io.element.android.libraries.matrix.impl.room
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
@ -19,7 +20,6 @@ import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -30,10 +30,7 @@ import org.junit.Test
class RustBaseRoomTest { class RustBaseRoomTest {
@Test @Test
fun `RustBaseRoom should cancel the room coroutine scope when it is destroyed`() = runTest { fun `RustBaseRoom should cancel the room coroutine scope when it is destroyed`() = runTest {
val rustBaseRoom = createRustBaseRoom( val rustBaseRoom = createRustBaseRoom()
// Not using backgroundScope here, but the test scope
sessionCoroutineScope = this
)
assertThat(rustBaseRoom.roomCoroutineScope.isActive).isTrue() assertThat(rustBaseRoom.roomCoroutineScope.isActive).isTrue()
rustBaseRoom.destroy() rustBaseRoom.destroy()
assertThat(rustBaseRoom.roomCoroutineScope.isActive).isFalse() assertThat(rustBaseRoom.roomCoroutineScope.isActive).isFalse()
@ -43,7 +40,6 @@ class RustBaseRoomTest {
fun `when currentUserMembership=JOINED and user leave room succeed then roomMembershipObserver emits change as LEFT`() = runTest { fun `when currentUserMembership=JOINED and user leave room succeed then roomMembershipObserver emits change as LEFT`() = runTest {
val roomMembershipObserver = RoomMembershipObserver() val roomMembershipObserver = RoomMembershipObserver()
val rustBaseRoom = createRustBaseRoom( val rustBaseRoom = createRustBaseRoom(
sessionCoroutineScope = this,
initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.JOINED), initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.JOINED),
innerRoom = FakeRustRoom( innerRoom = FakeRustRoom(
leaveLambda = { leaveLambda = {
@ -52,23 +48,18 @@ class RustBaseRoomTest {
), ),
roomMembershipObserver = roomMembershipObserver, roomMembershipObserver = roomMembershipObserver,
) )
val shared = roomMembershipObserver.updates.shareIn(scope = backgroundScope, started = SharingStarted.Eagerly, replay = 1) leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
rustBaseRoom.leave()
shared.test {
val membershipUpdate = awaitItem() val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId) assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isUserInRoom).isFalse() assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.LEFT) assertThat(membershipUpdate.change).isEqualTo(MembershipChange.LEFT)
ensureAllEventsConsumed()
} }
rustBaseRoom.destroy()
} }
@Test @Test
fun `when currentUserMembership=KNOCKED and user leave room succeed then roomMembershipObserver emits change as KNOCK_RETRACTED`() = runTest { fun `when currentUserMembership=KNOCKED and user leave room succeed then roomMembershipObserver emits change as KNOCK_RETRACTED`() = runTest {
val roomMembershipObserver = RoomMembershipObserver() val roomMembershipObserver = RoomMembershipObserver()
val rustBaseRoom = createRustBaseRoom( val rustBaseRoom = createRustBaseRoom(
sessionCoroutineScope = this,
initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.KNOCKED), initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.KNOCKED),
innerRoom = FakeRustRoom( innerRoom = FakeRustRoom(
leaveLambda = { leaveLambda = {
@ -77,23 +68,18 @@ class RustBaseRoomTest {
), ),
roomMembershipObserver = roomMembershipObserver, roomMembershipObserver = roomMembershipObserver,
) )
val shared = roomMembershipObserver.updates.shareIn(scope = backgroundScope, started = SharingStarted.Eagerly, replay = 1) leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
rustBaseRoom.leave()
shared.test {
val membershipUpdate = awaitItem() val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId) assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isUserInRoom).isFalse() assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.KNOCK_RETRACTED) assertThat(membershipUpdate.change).isEqualTo(MembershipChange.KNOCK_RETRACTED)
ensureAllEventsConsumed()
} }
rustBaseRoom.destroy()
} }
@Test @Test
fun `when currentUserMembership=INVITED and user leave room succeed then roomMembershipObserver emits change as INVITATION_REJECTED`() = runTest { fun `when currentUserMembership=INVITED and user leave room succeed then roomMembershipObserver emits change as INVITATION_REJECTED`() = runTest {
val roomMembershipObserver = RoomMembershipObserver() val roomMembershipObserver = RoomMembershipObserver()
val rustBaseRoom = createRustBaseRoom( val rustBaseRoom = createRustBaseRoom(
sessionCoroutineScope = this,
initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED), initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED),
innerRoom = FakeRustRoom( innerRoom = FakeRustRoom(
leaveLambda = { leaveLambda = {
@ -102,39 +88,44 @@ class RustBaseRoomTest {
), ),
roomMembershipObserver = roomMembershipObserver, roomMembershipObserver = roomMembershipObserver,
) )
val shared = roomMembershipObserver.updates.shareIn(scope = backgroundScope, started = SharingStarted.Eagerly, replay = 1) leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
rustBaseRoom.leave()
shared.test {
val membershipUpdate = awaitItem() val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId) assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isUserInRoom).isFalse() assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.INVITATION_REJECTED) assertThat(membershipUpdate.change).isEqualTo(MembershipChange.INVITATION_REJECTED)
ensureAllEventsConsumed()
} }
rustBaseRoom.destroy()
} }
@Test @Test
fun `when user leave room fails then roomMembershipObserver emits nothing`() = runTest { fun `when user leave room fails then roomMembershipObserver emits nothing`() = runTest {
val roomMembershipObserver = RoomMembershipObserver() val roomMembershipObserver = RoomMembershipObserver()
val rustBaseRoom = createRustBaseRoom( val rustBaseRoom = createRustBaseRoom(
sessionCoroutineScope = this,
initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED), initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED),
innerRoom = FakeRustRoom( innerRoom = FakeRustRoom(
leaveLambda = { error("Leave failed") } leaveLambda = { error("Leave failed") }
), ),
roomMembershipObserver = roomMembershipObserver, roomMembershipObserver = roomMembershipObserver,
) )
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
// No emit
}
}
private suspend fun TestScope.leaveRoomAndObserveMembershipChange(
roomMembershipObserver: RoomMembershipObserver,
rustBaseRoom: RustBaseRoom,
validate: suspend TurbineTestContext<RoomMembershipObserver.RoomMembershipUpdate>.() -> Unit
) {
val shared = roomMembershipObserver.updates.shareIn(scope = backgroundScope, started = SharingStarted.Eagerly, replay = 1) val shared = roomMembershipObserver.updates.shareIn(scope = backgroundScope, started = SharingStarted.Eagerly, replay = 1)
rustBaseRoom.leave() rustBaseRoom.leave()
shared.test { shared.test {
validate()
ensureAllEventsConsumed() ensureAllEventsConsumed()
} }
rustBaseRoom.destroy() rustBaseRoom.destroy()
} }
private fun TestScope.createRustBaseRoom( private fun TestScope.createRustBaseRoom(
sessionCoroutineScope: CoroutineScope,
initialRoomInfo: RoomInfo = aRoomInfo(), initialRoomInfo: RoomInfo = aRoomInfo(),
innerRoom: FakeRustRoom = FakeRustRoom(), innerRoom: FakeRustRoom = FakeRustRoom(),
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
@ -150,7 +141,8 @@ class RustBaseRoomTest {
dispatchers = dispatchers, dispatchers = dispatchers,
), ),
roomMembershipObserver = roomMembershipObserver, roomMembershipObserver = roomMembershipObserver,
sessionCoroutineScope = sessionCoroutineScope, // Not using backgroundScope here, but the test scope
sessionCoroutineScope = this,
roomInfoMapper = RoomInfoMapper(), roomInfoMapper = RoomInfoMapper(),
initialRoomInfo = initialRoomInfo, initialRoomInfo = initialRoomInfo,
) )

View file

@ -13,7 +13,6 @@ import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomDescription import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomDescription
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoomDirectorySearch import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoomDirectorySearch
import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -31,7 +30,6 @@ class RustBaseRoomDirectoryListTest {
val mapper = RoomDescriptionMapper() val mapper = RoomDescriptionMapper()
val sut = createRustRoomDirectoryList( val sut = createRustRoomDirectoryList(
roomDirectorySearch = roomDirectorySearch, roomDirectorySearch = roomDirectorySearch,
scope = backgroundScope,
) )
// Let the mxCallback be ready // Let the mxCallback be ready
runCurrent() runCurrent()
@ -81,10 +79,9 @@ class RustBaseRoomDirectoryListTest {
private fun TestScope.createRustRoomDirectoryList( private fun TestScope.createRustRoomDirectoryList(
roomDirectorySearch: RoomDirectorySearch = FakeRustRoomDirectorySearch(), roomDirectorySearch: RoomDirectorySearch = FakeRustRoomDirectorySearch(),
scope: CoroutineScope,
) = RustRoomDirectoryList( ) = RustRoomDirectoryList(
inner = roomDirectorySearch, inner = roomDirectorySearch,
coroutineScope = scope, coroutineScope = backgroundScope,
coroutineContext = StandardTestDispatcher(testScheduler), coroutineContext = StandardTestDispatcher(testScheduler),
) )
} }

View file

@ -8,8 +8,9 @@
package io.element.android.libraries.matrix.impl.roomlist package io.element.android.libraries.matrix.impl.roomlist
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoomListItem import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoom
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoomListService import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoomListService
import io.element.android.libraries.matrix.test.A_ROOM_ID 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_2
@ -30,7 +31,7 @@ class RoomSummaryListProcessorTest {
summaries.value = listOf(aRoomSummary()) summaries.value = listOf(aRoomSummary())
val processor = createProcessor() val processor = createProcessor()
val newEntry = FakeRustRoomListItem(A_ROOM_ID_2) val newEntry = aRustRoom(A_ROOM_ID_2)
processor.postUpdate(listOf(RoomListEntriesUpdate.Append(listOf(newEntry, newEntry, newEntry)))) processor.postUpdate(listOf(RoomListEntriesUpdate.Append(listOf(newEntry, newEntry, newEntry))))
assertThat(summaries.value.count()).isEqualTo(4) assertThat(summaries.value.count()).isEqualTo(4)
@ -41,7 +42,7 @@ class RoomSummaryListProcessorTest {
fun `PushBack adds a new entry at the end of the list`() = runTest { fun `PushBack adds a new entry at the end of the list`() = runTest {
summaries.value = listOf(aRoomSummary()) summaries.value = listOf(aRoomSummary())
val processor = createProcessor() val processor = createProcessor()
processor.postUpdate(listOf(RoomListEntriesUpdate.PushBack(FakeRustRoomListItem(A_ROOM_ID_2)))) processor.postUpdate(listOf(RoomListEntriesUpdate.PushBack(aRustRoom(A_ROOM_ID_2))))
assertThat(summaries.value.count()).isEqualTo(2) assertThat(summaries.value.count()).isEqualTo(2)
assertThat(summaries.value.last().roomId).isEqualTo(A_ROOM_ID_2) assertThat(summaries.value.last().roomId).isEqualTo(A_ROOM_ID_2)
@ -51,7 +52,7 @@ class RoomSummaryListProcessorTest {
fun `PushFront inserts a new entry at the start of the list`() = runTest { fun `PushFront inserts a new entry at the start of the list`() = runTest {
summaries.value = listOf(aRoomSummary()) summaries.value = listOf(aRoomSummary())
val processor = createProcessor() val processor = createProcessor()
processor.postUpdate(listOf(RoomListEntriesUpdate.PushFront(FakeRustRoomListItem(A_ROOM_ID_2)))) processor.postUpdate(listOf(RoomListEntriesUpdate.PushFront(aRustRoom(A_ROOM_ID_2))))
assertThat(summaries.value.count()).isEqualTo(2) assertThat(summaries.value.count()).isEqualTo(2)
assertThat(summaries.value.first().roomId).isEqualTo(A_ROOM_ID_2) assertThat(summaries.value.first().roomId).isEqualTo(A_ROOM_ID_2)
@ -63,7 +64,7 @@ class RoomSummaryListProcessorTest {
val processor = createProcessor() val processor = createProcessor()
val index = 0 val index = 0
processor.postUpdate(listOf(RoomListEntriesUpdate.Set(index.toUInt(), FakeRustRoomListItem(A_ROOM_ID_2)))) processor.postUpdate(listOf(RoomListEntriesUpdate.Set(index.toUInt(), aRustRoom(A_ROOM_ID_2))))
assertThat(summaries.value.count()).isEqualTo(1) assertThat(summaries.value.count()).isEqualTo(1)
assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2) assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2)
@ -75,7 +76,7 @@ class RoomSummaryListProcessorTest {
val processor = createProcessor() val processor = createProcessor()
val index = 0 val index = 0
processor.postUpdate(listOf(RoomListEntriesUpdate.Insert(index.toUInt(), FakeRustRoomListItem(A_ROOM_ID_2)))) processor.postUpdate(listOf(RoomListEntriesUpdate.Insert(index.toUInt(), aRustRoom(A_ROOM_ID_2))))
assertThat(summaries.value.count()).isEqualTo(2) assertThat(summaries.value.count()).isEqualTo(2)
assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2) assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2)
@ -163,12 +164,17 @@ class RoomSummaryListProcessorTest {
val processor = createProcessor() val processor = createProcessor()
val index = 0 val index = 0
processor.postUpdate(listOf(RoomListEntriesUpdate.Reset(listOf(FakeRustRoomListItem(A_ROOM_ID_3))))) processor.postUpdate(listOf(RoomListEntriesUpdate.Reset(listOf(aRustRoom(A_ROOM_ID_3)))))
assertThat(summaries.value.count()).isEqualTo(1) assertThat(summaries.value.count()).isEqualTo(1)
assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_3) assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_3)
} }
private fun aRustRoom(roomId: RoomId = A_ROOM_ID) = FakeRustRoom(
roomId = roomId,
latestEventLambda = { null },
)
private fun TestScope.createProcessor() = RoomSummaryListProcessor( private fun TestScope.createProcessor() = RoomSummaryListProcessor(
summaries, summaries,
FakeRustRoomListService(), FakeRustRoomListService(),

View file

@ -12,7 +12,6 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoomListService import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoomListService
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -28,7 +27,6 @@ class RustBaseRoomListServiceTest {
fun `syncIndicator should emit the expected values`() = runTest { fun `syncIndicator should emit the expected values`() = runTest {
val roomListService = FakeRustRoomListService() val roomListService = FakeRustRoomListService()
val sut = createRustRoomListService( val sut = createRustRoomListService(
sessionCoroutineScope = backgroundScope,
roomListService = roomListService, roomListService = roomListService,
) )
// Give time for mxCallback to setup // Give time for mxCallback to setup
@ -44,18 +42,17 @@ class RustBaseRoomListServiceTest {
} }
private fun TestScope.createRustRoomListService( private fun TestScope.createRustRoomListService(
sessionCoroutineScope: CoroutineScope,
roomListService: RustRoomListService = FakeRustRoomListService(), roomListService: RustRoomListService = FakeRustRoomListService(),
) = RustRoomListService( ) = RustRoomListService(
innerRoomListService = roomListService, innerRoomListService = roomListService,
sessionDispatcher = StandardTestDispatcher(testScheduler), sessionDispatcher = StandardTestDispatcher(testScheduler),
roomListFactory = RoomListFactory( roomListFactory = RoomListFactory(
innerRoomListService = roomListService, innerRoomListService = roomListService,
sessionCoroutineScope = sessionCoroutineScope, sessionCoroutineScope = backgroundScope,
), ),
roomSyncSubscriber = RoomSyncSubscriber( roomSyncSubscriber = RoomSyncSubscriber(
roomListService = roomListService, roomListService = roomListService,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
), ),
sessionCoroutineScope = sessionCoroutineScope, sessionCoroutineScope = backgroundScope,
) )

View file

@ -17,7 +17,6 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustTimelineI
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -38,7 +37,6 @@ class TimelineItemsSubscriberTest {
MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE)
val timeline = FakeRustTimeline() val timeline = FakeRustTimeline()
val timelineItemsSubscriber = createTimelineItemsSubscriber( val timelineItemsSubscriber = createTimelineItemsSubscriber(
coroutineScope = backgroundScope,
timeline = timeline, timeline = timeline,
timelineItems = timelineItems, timelineItems = timelineItems,
) )
@ -59,7 +57,6 @@ class TimelineItemsSubscriberTest {
MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE)
val timeline = FakeRustTimeline() val timeline = FakeRustTimeline()
val timelineItemsSubscriber = createTimelineItemsSubscriber( val timelineItemsSubscriber = createTimelineItemsSubscriber(
coroutineScope = backgroundScope,
timeline = timeline, timeline = timeline,
timelineItems = timelineItems, timelineItems = timelineItems,
) )
@ -81,7 +78,6 @@ class TimelineItemsSubscriberTest {
val timeline = FakeRustTimeline() val timeline = FakeRustTimeline()
val onNewSyncedEventRecorder = lambdaRecorder<Unit> { } val onNewSyncedEventRecorder = lambdaRecorder<Unit> { }
val timelineItemsSubscriber = createTimelineItemsSubscriber( val timelineItemsSubscriber = createTimelineItemsSubscriber(
coroutineScope = backgroundScope,
timeline = timeline, timeline = timeline,
timelineItems = timelineItems, timelineItems = timelineItems,
onNewSyncedEvent = onNewSyncedEventRecorder, onNewSyncedEvent = onNewSyncedEventRecorder,
@ -109,9 +105,7 @@ class TimelineItemsSubscriberTest {
@Test @Test
fun `multiple subscriptions does not have side effect`() = runTest { fun `multiple subscriptions does not have side effect`() = runTest {
val timelineItemsSubscriber = createTimelineItemsSubscriber( val timelineItemsSubscriber = createTimelineItemsSubscriber()
coroutineScope = backgroundScope,
)
timelineItemsSubscriber.subscribeIfNeeded() timelineItemsSubscriber.subscribeIfNeeded()
timelineItemsSubscriber.subscribeIfNeeded() timelineItemsSubscriber.subscribeIfNeeded()
timelineItemsSubscriber.unsubscribeIfNeeded() timelineItemsSubscriber.unsubscribeIfNeeded()
@ -120,7 +114,6 @@ class TimelineItemsSubscriberTest {
} }
private fun TestScope.createTimelineItemsSubscriber( private fun TestScope.createTimelineItemsSubscriber(
coroutineScope: CoroutineScope,
timeline: Timeline = FakeRustTimeline(), timeline: Timeline = FakeRustTimeline(),
timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> = MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE), timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> = MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE),
initLatch: CompletableDeferred<Unit> = CompletableDeferred(), initLatch: CompletableDeferred<Unit> = CompletableDeferred(),
@ -128,7 +121,7 @@ private fun TestScope.createTimelineItemsSubscriber(
onNewSyncedEvent: () -> Unit = { lambdaError() }, onNewSyncedEvent: () -> Unit = { lambdaError() },
): TimelineItemsSubscriber { ): TimelineItemsSubscriber {
return TimelineItemsSubscriber( return TimelineItemsSubscriber(
timelineCoroutineScope = coroutineScope, timelineCoroutineScope = backgroundScope,
dispatcher = StandardTestDispatcher(testScheduler), dispatcher = StandardTestDispatcher(testScheduler),
timeline = timeline, timeline = timeline,
timelineDiffProcessor = createMatrixTimelineDiffProcessor(timelineItems), timelineDiffProcessor = createMatrixTimelineDiffProcessor(timelineItems),

View file

@ -69,6 +69,10 @@ const val A_REDACTION_REASON = "A redaction reason"
const val A_HOMESERVER_URL = "matrix.org" const val A_HOMESERVER_URL = "matrix.org"
const val A_HOMESERVER_URL_2 = "matrix-client.org" const val A_HOMESERVER_URL_2 = "matrix-client.org"
const val AN_ACCOUNT_PROVIDER = "matrix.org"
const val AN_ACCOUNT_PROVIDER_2 = "element.io"
const val AN_ACCOUNT_PROVIDER_3 = "other.io"
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false) val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false)
val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true) val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true)
val A_ROOM_NOTIFICATION_MODE = RoomNotificationMode.MUTE val A_ROOM_NOTIFICATION_MODE = RoomNotificationMode.MUTE

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