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 091d41b09d
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
ARCH: x86_64
DEVICE: pixel_7_pro
API_LEVEL: 35
API_LEVEL: 33
TARGET: google_apis
jobs:

View file

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

View file

@ -14,7 +14,22 @@ appId: ${MAESTRO_APP_ID}
visible: 'Use without an account'
commands:
- 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.
- extendedWaitUntil:
visible:
id: "form-1"
timeout: 10000
- tapOn:
id: "form-1"
- 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.permalink.PermalinkData
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -51,6 +52,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
private val appNavigationStateService: AppNavigationStateService,
private val appCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient,
private val activeRoomsHolder: ActiveRoomsHolder,
roomComponentFactory: RoomComponentFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack(
@ -85,6 +87,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
onCreate = {
Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
activeRoomsHolder.addRoom(inputs.room)
fetchRoomMembers()
trackVisitedRoom()
},
@ -95,6 +98,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
},
onDestroy = {
Timber.v("OnDestroy")
activeRoomsHolder.removeRoom(inputs.room.sessionId, inputs.room.roomId)
inputs.room.destroy()
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.libraries.architecture.childNode
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.room.FakeBaseRoom
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class JoinBaseRoomLoadedFlowNodeTest {
class JoinedRoomLoadedFlowNodeTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@ -96,20 +98,21 @@ class JoinBaseRoomLoadedFlowNodeTest {
}
}
private fun createJoinedRoomLoadedFlowNode(
private fun TestScope.createJoinedRoomLoadedFlowNode(
plugins: List<Plugin>,
messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
coroutineScope: CoroutineScope,
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
) = JoinedRoomLoadedFlowNode(
buildContext = BuildContext.root(savedStateMap = null),
plugins = plugins,
messagesEntryPoint = messagesEntryPoint,
roomDetailsEntryPoint = roomDetailsEntryPoint,
appNavigationStateService = FakeAppNavigationStateService(),
appCoroutineScope = coroutineScope,
appCoroutineScope = this,
roomComponentFactory = FakeRoomComponentFactory(),
matrixClient = FakeMatrixClient(),
activeRoomsHolder = activeRoomsHolder,
)
@Test
@ -121,7 +124,6 @@ class JoinBaseRoomLoadedFlowNodeTest {
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint,
coroutineScope = this
)
// WHEN
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
@ -144,7 +146,6 @@ class JoinBaseRoomLoadedFlowNodeTest {
plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint,
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
coroutineScope = this
)
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// WHEN
@ -154,4 +155,53 @@ class JoinBaseRoomLoadedFlowNodeTest {
val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!!
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.network.useragent.UserAgentProvider
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.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
@ -62,6 +63,7 @@ class CallScreenPresenter @AssistedInject constructor(
private val activeCallManager: ActiveCallManager,
private val languageTagProvider: LanguageTagProvider,
private val appForegroundStateService: AppForegroundStateService,
private val activeRoomsHolder: ActiveRoomsHolder,
private val appCoroutineScope: CoroutineScope,
) : Presenter<CallScreenState> {
@AssistedFactory
@ -241,8 +243,10 @@ class CallScreenPresenter @AssistedInject constructor(
private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) {
if (!notifiedCallStart) {
getJoinedRoom(roomId)?.use { it.sendCallNotificationIfNeeded() }
?.onSuccess { notifiedCallStart = true }
val activeRoomForSession = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
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.widget.CallWidgetSettingsProvider
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
@ -24,6 +25,7 @@ class DefaultCallWidgetProvider @Inject constructor(
private val matrixClientsProvider: MatrixClientProvider,
private val appPreferencesStore: AppPreferencesStore,
private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
private val activeRoomsHolder: ActiveRoomsHolder,
) : CallWidgetProvider {
override suspend fun getWidget(
sessionId: SessionId,
@ -33,7 +35,9 @@ class DefaultCallWidgetProvider @Inject constructor(
theme: String?,
): Result<CallWidgetProvider.GetWidgetResult> = runCatching {
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 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.services.analytics.api.ScreenTracker
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.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule
@ -367,6 +368,7 @@ import kotlin.time.Duration.Companion.seconds
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
screenTracker: ScreenTracker = FakeScreenTracker(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
): CallScreenPresenter {
val userAgentProvider = object : UserAgentProvider {
override fun provide(): String {
@ -387,6 +389,7 @@ import kotlin.time.Duration.Companion.seconds
languageTagProvider = FakeLanguageTagProvider("en-US"),
appForegroundStateService = appForegroundStateService,
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.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
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.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
@ -63,30 +59,28 @@ class DefaultActiveCallManagerTest {
fun `registerIncomingCall - sets the incoming call as active`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull()
val callNotificationData = aCallNotificationData()
manager.registerIncomingCall(callNotificationData)
val callNotificationData = aCallNotificationData()
manager.registerIncomingCall(callNotificationData)
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
),
callState = CallState.Ringing(callNotificationData)
)
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
),
callState = CallState.Ringing(callNotificationData)
)
)
runCurrent()
runCurrent()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify { notificationManagerCompat.notify(notificationId, any()) }
}
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify { notificationManagerCompat.notify(notificationId, any()) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@ -94,42 +88,38 @@ class DefaultActiveCallManagerTest {
fun `registerIncomingCall - when there is an already active call adds missed call notification`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
val onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
inCancellableScope {
val manager = createActiveCallManager(
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
)
val manager = createActiveCallManager(
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
)
// Register existing call
val callNotificationData = aCallNotificationData()
manager.registerIncomingCall(callNotificationData)
val activeCall = manager.activeCall.value
// Register existing call
val callNotificationData = aCallNotificationData()
manager.registerIncomingCall(callNotificationData)
val activeCall = manager.activeCall.value
// Now add a new call
manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
// Now add a new call
manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
assertThat(manager.activeCall.value).isEqualTo(activeCall)
assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2)
assertThat(manager.activeCall.value).isEqualTo(activeCall)
assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2)
advanceTimeBy(1)
advanceTimeBy(1)
addMissedCallNotificationLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID))
}
addMissedCallNotificationLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID))
}
@Test
fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
inCancellableScope {
val manager = createActiveCallManager(
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
)
val manager = createActiveCallManager(
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
)
manager.incomingCallTimedOut(displayMissedCallNotification = true)
manager.incomingCallTimedOut(displayMissedCallNotification = true)
addMissedCallNotificationLambda.assertions().isNeverCalled()
}
addMissedCallNotificationLambda.assertions().isNeverCalled()
}
@OptIn(ExperimentalCoroutinesApi::class)
@ -138,90 +128,80 @@ class DefaultActiveCallManagerTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
inCancellableScope {
val manager = createActiveCallManager(
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
notificationManagerCompat = notificationManagerCompat,
)
val manager = createActiveCallManager(
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
notificationManagerCompat = notificationManagerCompat,
)
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.incomingCallTimedOut(displayMissedCallNotification = true)
advanceTimeBy(1)
manager.incomingCallTimedOut(displayMissedCallNotification = true)
advanceTimeBy(1)
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
addMissedCallNotificationLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) }
}
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
addMissedCallNotificationLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) }
}
@Test
fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
val notificationData = aCallNotificationData()
manager.registerIncomingCall(notificationData)
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
val notificationData = aCallNotificationData()
manager.registerIncomingCall(notificationData)
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify { notificationManagerCompat.cancel(notificationId) }
}
verify { notificationManagerCompat.cancel(notificationId) }
}
@Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
}
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `joinedCall - register an ongoing call and tries sending the call notify event`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeCall.value).isNull()
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeCall.value).isNull()
manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
),
callState = CallState.InCall,
)
manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
),
callState = CallState.InCall,
)
)
runCurrent()
runCurrent()
verify { notificationManagerCompat.cancel(notificationId) }
}
verify { notificationManagerCompat.cancel(notificationId) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@ -233,22 +213,19 @@ class DefaultActiveCallManagerTest {
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
// Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope {
val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
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)
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1)
// Call is cancelled (the other user left the call)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1)
// Call is active (the other user join the call)
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1)
// Call is cancelled (the other user left the call)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1)
assertThat(manager.activeCall.value).isNull()
}
assertThat(manager.activeCall.value).isNull()
}
@OptIn(ExperimentalCoroutinesApi::class)
@ -260,44 +237,34 @@ class DefaultActiveCallManagerTest {
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
// Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope {
val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("Matrix client not found")) })
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))
advanceTimeBy(1)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1)
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1)
// The call should still be active
assertThat(manager.activeCall.value).isNotNull()
// The call should still be active
assertThat(manager.activeCall.value).isNotNull()
// No room
client.givenGetRoomResult(A_ROOM_ID, null)
matrixClientProvider.getClient = { Result.success(client) }
// No room
client.givenGetRoomResult(A_ROOM_ID, null)
matrixClientProvider.getClient = { Result.success(client) }
manager.registerIncomingCall(aCallNotificationData())
manager.registerIncomingCall(aCallNotificationData())
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1)
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1)
// The call should still be active
assertThat(manager.activeCall.value).isNotNull()
}
}
private fun TestScope.inCancellableScope(block: suspend CoroutineScope.() -> Unit) {
launch(SupervisorJob()) {
block()
cancel()
}
// The call should still be active
assertThat(manager.activeCall.value).isNotNull()
}
private fun setupShadowPowerManager() {
@ -306,14 +273,13 @@ class DefaultActiveCallManagerTest {
}
}
private fun CoroutineScope.createActiveCallManager(
private fun TestScope.createActiveCallManager(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
coroutineScope: CoroutineScope = this,
) = DefaultActiveCallManager(
context = InstrumentationRegistry.getInstrumentation().targetContext,
coroutineScope = coroutineScope,
coroutineScope = backgroundScope,
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
ringingCallNotificationCreator = RingingCallNotificationCreator(
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.FakeMatrixClient
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.widget.FakeCallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -77,6 +79,29 @@ class DefaultCallWidgetProviderTest {
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
fun `getWidget - will use a custom base url if it exists`() = runTest {
val room = FakeJoinedRoom(
@ -104,9 +129,11 @@ class DefaultCallWidgetProviderTest {
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
) = DefaultCallWidgetProvider(
matrixClientsProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore,
callWidgetSettingsProvider = callWidgetSettingsProvider,
activeRoomsHolder = activeRoomsHolder,
)
}

View file

@ -16,9 +16,21 @@ interface EnterpriseService {
fun defaultHomeserverList(): List<String>
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
suspend fun isElementCallAvailable(): Boolean
fun semanticColorsLight(): SemanticColors
fun semanticColorsDark(): SemanticColors
fun firebasePushGateway(): 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 suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
override suspend fun isElementCallAvailable(): Boolean = true
override fun semanticColorsLight(): SemanticColors = compoundColorsLight
override fun semanticColorsDark(): SemanticColors = compoundColorsDark

View file

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

View file

@ -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.consumeItemsUntilPredicate
import io.element.android.tests.testutils.test
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LockScreenSettingsPresenterTest {
@Test
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 {
awaitItem().also { state ->
assertThat(state.showRemovePinOption).isFalse()
@ -37,7 +37,7 @@ class LockScreenSettingsPresenterTest {
@Test
fun `present - remove pin flow`() = runTest {
val presenter = createLockScreenSettingsPresenter(this)
val presenter = createLockScreenSettingsPresenter()
presenter.test {
consumeItemsUntilPredicate { state ->
state.showRemovePinOption
@ -71,7 +71,6 @@ class LockScreenSettingsPresenterTest {
isDeviceSecured = true,
)
val presenter = createLockScreenSettingsPresenter(
coroutineScope = this,
biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
)
presenter.test {
@ -88,7 +87,6 @@ class LockScreenSettingsPresenterTest {
}
)
val presenter = createLockScreenSettingsPresenter(
coroutineScope = this,
biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
)
presenter.test {
@ -110,7 +108,6 @@ class LockScreenSettingsPresenterTest {
}
)
val presenter = createLockScreenSettingsPresenter(
coroutineScope = this,
biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
)
presenter.test {
@ -130,7 +127,6 @@ class LockScreenSettingsPresenterTest {
)
val lockScreenStore = InMemoryLockScreenStore()
val presenter = createLockScreenSettingsPresenter(
coroutineScope = this,
lockScreenStore = lockScreenStore,
biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
)
@ -148,8 +144,7 @@ class LockScreenSettingsPresenterTest {
}
}
private suspend fun createLockScreenSettingsPresenter(
coroutineScope: CoroutineScope,
private suspend fun TestScope.createLockScreenSettingsPresenter(
lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
@ -160,7 +155,7 @@ class LockScreenSettingsPresenterTest {
return LockScreenSettingsPresenter(
lockScreenStore = lockScreenStore,
pinCodeManager = pinCodeManager,
coroutineScope = coroutineScope,
coroutineScope = this,
lockScreenConfig = lockScreenConfig,
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.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -34,7 +34,7 @@ class PinUnlockPresenterTest {
@Test
fun `present - success verify flow`() = runTest {
val presenter = createPinUnlockPresenter(this)
val presenter = createPinUnlockPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -71,7 +71,7 @@ class PinUnlockPresenterTest {
@Test
fun `present - failure verify flow`() = runTest {
val presenter = createPinUnlockPresenter(this)
val presenter = createPinUnlockPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -100,7 +100,7 @@ class PinUnlockPresenterTest {
fun `present - forgot pin flow`() = runTest {
val signOutLambda = lambdaRecorder<Boolean, Unit> {}
val signOut = FakeLogoutUseCase(signOutLambda)
val presenter = createPinUnlockPresenter(this, logoutUseCase = signOut)
val presenter = createPinUnlockPresenter(logoutUseCase = signOut)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -135,8 +135,7 @@ class PinUnlockPresenterTest {
dataOrNull()?.assertText(text)
}
private suspend fun createPinUnlockPresenter(
scope: CoroutineScope,
private suspend fun TestScope.createPinUnlockPresenter(
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = { "" }),
@ -149,7 +148,7 @@ class PinUnlockPresenterTest {
pinCodeManager = pinCodeManager,
biometricAuthenticatorManager = biometricAuthenticatorManager,
logoutUseCase = logoutUseCase,
coroutineScope = scope,
coroutineScope = this,
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.qrcode.QrCodeLoginFlowNode
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.createaccount.CreateAccountNode
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
@ -107,6 +108,9 @@ class LoginFlowNode @AssistedInject constructor(
val isAccountCreation: Boolean,
) : NavTarget
@Parcelize
data object ChooseAccountProvider : NavTarget
@Parcelize
data object ChangeAccountProvider : NavTarget
@ -133,9 +137,13 @@ class LoginFlowNode @AssistedInject constructor(
)
}
override fun onSignIn() {
override fun onSignIn(mustChooseAccountProvider: Boolean) {
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))
}
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 -> {
createNode<QrCodeLoginFlowNode>(buildContext)
}

View file

@ -20,15 +20,16 @@ import javax.inject.Inject
class AccountProviderDataSource @Inject constructor(
enterpriseService: EnterpriseService,
) {
private val defaultAccountProvider = (enterpriseService.defaultHomeserverList().firstOrNull() ?: AuthenticationConfig.MATRIX_ORG_URL)
.let { url ->
AccountProvider(
url = url,
subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
)
}
private val defaultAccountProvider =
(enterpriseService.defaultHomeserverList().firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } ?: AuthenticationConfig.MATRIX_ORG_URL)
.let { url ->
AccountProvider(
url = url,
subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
)
}
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(
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(
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(
url = url,
subtitle = "Matrix.org is an open network for secure, decentralized communication.",
isPublic = true,
isMatrixOrg = true,
isValid = true,
subtitle = subtitle,
isPublic = isPublic,
isMatrixOrg = isMatrixOrg,
isValid = isValid,
)

View file

@ -39,6 +39,7 @@ fun AccountProviderView(
item: AccountProvider,
onClick: () -> Unit,
modifier: Modifier = Modifier,
selected: Boolean = false,
) {
Column(
modifier = modifier
@ -66,7 +67,7 @@ fun AccountProviderView(
} else {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
imageVector = CompoundIcons.Search(),
imageVector = CompoundIcons.Host(),
tint = ElementTheme.colors.iconPrimary,
)
}
@ -88,6 +89,15 @@ fun AccountProviderView(
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) {
Text(

View file

@ -14,6 +14,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import io.element.android.features.login.impl.DefaultLoginUserStory
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.createaccount.AccountCreationNotSupported
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
* 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(
private val oidcActionFlow: OidcActionFlow,

View file

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

View file

@ -8,20 +8,28 @@
package io.element.android.features.login.impl.screens.changeaccountprovider
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.changeserver.ChangeServerState
import io.element.android.features.login.impl.changeserver.aChangeServerState
open class ChangeAccountProviderStateProvider : PreviewParameterProvider<ChangeAccountProviderState> {
override val values: Sequence<ChangeAccountProviderState>
get() = sequenceOf(
aChangeAccountProviderState(),
aChangeAccountProviderState(canSearchForAccountProviders = false),
// Add other state here
)
}
fun aChangeAccountProviderState() = ChangeAccountProviderState(
accountProviders = listOf(
fun aChangeAccountProviderState(
accountProviders: List<AccountProvider> = listOf(
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 io.element.android.compound.tokens.generated.CompoundIcons
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.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView
@ -95,13 +95,11 @@ fun ChangeAccountProviderView(
)
}
// Other
AccountProviderView(
item = AccountProvider(
url = "",
title = stringResource(id = R.string.screen_change_account_provider_other),
),
onClick = onOtherProviderClick
)
if (state.canSearchForAccountProviders) {
AccountProviderOtherView(
onClick = onOtherProviderClick
)
}
Spacer(Modifier.height(32.dp))
}
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 {
fun onSignUp()
fun onSignIn()
fun onSignIn(mustChooseAccountProvider: Boolean)
fun onSignInWithQrCode()
fun onReportProblem()
fun onLoginPasswordNeeded()
@ -53,8 +53,8 @@ class OnBoardingNode @AssistedInject constructor(
params = params,
)
private fun onSignIn() {
plugins<Callback>().forEach { it.onSignIn() }
private fun onSignIn(mustChooseAccountProvider: Boolean) {
plugins<Callback>().forEach { it.onSignIn(mustChooseAccountProvider) }
}
private fun onSignUp() {

View file

@ -16,6 +16,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
@ -27,6 +29,7 @@ class OnBoardingPresenter @AssistedInject constructor(
@Assisted private val params: OnBoardingNode.Params,
private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
private val enterpriseService: EnterpriseService,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val loginHelper: LoginHelper,
) : Presenter<OnBoardingState> {
@ -37,15 +40,33 @@ class OnBoardingPresenter @AssistedInject constructor(
): OnBoardingPresenter
}
private val defaultAccountProvider = params.accountProvider
private val loginHint = params.loginHint
@Composable
override fun present(): OnBoardingState {
val localCoroutineScope = rememberCoroutineScope()
val canLoginWithQrCode by produceState(initialValue = false) {
value = defaultAccountProvider == null &&
val forcedAccountProvider = remember {
// If defaultHomeserverList() returns a singleton list, this is the default account provider.
// 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)
}
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
@ -58,7 +79,7 @@ class OnBoardingPresenter @AssistedInject constructor(
coroutineScope = localCoroutineScope,
isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider,
loginHint = loginHint,
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
)
OnBoardingEvents.ClearError -> loginHelper.clearError()
}
@ -67,8 +88,9 @@ class OnBoardingPresenter @AssistedInject constructor(
return OnBoardingState(
productionApplicationName = buildMeta.productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider,
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = defaultAccountProvider == null && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canCreateAccount = defaultAccountProvider == null && canConnectToAnyHomeserver && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug,
loginMode = loginMode,
eventSink = ::handleEvent,

View file

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

View file

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

View file

@ -56,7 +56,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun OnBoardingView(
state: OnBoardingState,
onSignInWithQrCode: () -> Unit,
onSignIn: () -> Unit,
onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
onCreateAccount: () -> Unit,
onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
@ -143,7 +143,7 @@ private fun OnBoardingContent(state: OnBoardingState) {
private fun OnBoardingButtons(
state: OnBoardingState,
onSignInWithQrCode: () -> Unit,
onSignIn: () -> Unit,
onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
onCreateAccount: () -> Unit,
onReportProblem: () -> Unit,
) {
@ -171,7 +171,9 @@ private fun OnBoardingButtons(
if (defaultAccountProvider == null) {
Button(
text = stringResource(id = signInButtonStringRes),
onClick = onSignIn,
onClick = {
onSignIn(state.mustChooseAccountProvider)
},
modifier = Modifier
.fillMaxWidth()
.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_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_picker_mode">"Choose account provider"</string>
<string name="screen_server_confirmation_title_register">"Youre about to create an account on %1$s"</string>
</resources>

View file

@ -10,6 +10,7 @@ package io.element.android.features.login.impl.accountprovider
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
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.tests.testutils.WarmUpRule
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
fun `present - user change and reset`() = runTest {
val sut = AccountProviderDataSource(FakeEnterpriseService())

View file

@ -11,9 +11,12 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.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.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 kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -27,7 +30,9 @@ class ChangeAccountProviderPresenterTest {
fun `present - initial state`() = runTest {
val presenter = ChangeAccountProviderPresenter(
changeServerPresenter = { aChangeServerState() },
enterpriseService = FakeEnterpriseService(),
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { emptyList() }
),
)
moleculeFlow(RecompositionMode.Immediate) {
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 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.login.LoginHelper
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.test.FakeFeatureFlagService
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_LOGIN_HINT
import io.element.android.libraries.matrix.test.A_THROWABLE
@ -36,6 +41,23 @@ class OnBoardingPresenterTest {
@get:Rule
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
fun `present - initial state`() = runTest {
val buildMeta = aBuildMeta(
@ -50,10 +72,14 @@ class OnBoardingPresenterTest {
val presenter = createPresenter(
buildMeta = buildMeta,
featureFlagService = featureFlagService,
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
),
rageshakeFeatureAvailability = { true },
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.defaultAccountProvider).isNull()
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
@ -74,22 +100,79 @@ class OnBoardingPresenterTest {
}
@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(
params = OnBoardingNode.Params(
accountProvider = A_HOMESERVER_URL,
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
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 {
skipItems(3)
awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
assertThat(it.defaultAccountProvider).isEqualTo(ACCOUNT_PROVIDER_FROM_LINK)
assertThat(it.canLoginWithQrCode).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
fun `present - default account provider - login and clear error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
@ -98,11 +181,15 @@ class OnBoardingPresenterTest {
accountProvider = A_HOMESERVER_URL,
loginHint = A_LOGIN_HINT,
),
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
),
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
)
presenter.test {
skipItems(3)
awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
authenticationService.givenChangeServerError(A_THROWABLE)
@ -126,12 +213,14 @@ private fun createPresenter(
params: OnBoardingNode.Params = OnBoardingNode.Params(null, null),
buildMeta: BuildMeta = aBuildMeta(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
rageshakeFeatureAvailability: () -> Boolean = { true },
loginHelper: LoginHelper = createLoginHelper(),
) = OnBoardingPresenter(
params = params,
buildMeta = buildMeta,
featureFlagService = featureFlagService,
enterpriseService = enterpriseService,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
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.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@ -56,10 +57,28 @@ class OnboardingViewTest {
}
@Test
fun `when can login with QR code - clicking on sign in manually calls the expected callback`() {
ensureCalledOnce { callback ->
fun `when can login with QR code - clicking on sign in manually calls the expected callback - can search account provider`() {
`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(
state = anOnBoardingState(canLoginWithQrCode = true),
state = anOnBoardingState(
canLoginWithQrCode = true,
mustChooseAccountProvider = mustChooseAccountProvider,
),
onSignIn = callback,
)
rule.clickOn(R.string.screen_onboarding_sign_in_manually)
@ -67,12 +86,28 @@ class OnboardingViewTest {
}
@Test
fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`() {
ensureCalledOnce { callback ->
fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback - can search account provider`() {
`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(
state = anOnBoardingState(
canLoginWithQrCode = false,
canCreateAccount = false,
mustChooseAccountProvider = mustChooseAccountProvider,
),
onSignIn = callback,
)
@ -137,7 +172,7 @@ class OnboardingViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
state: OnBoardingState,
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
onSignIn: () -> Unit = EnsureNeverCalled(),
onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
onCreateAccount: () -> Unit = EnsureNeverCalled(),
onReportProblem: () -> Unit = EnsureNeverCalled(),
onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(),

View file

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

View file

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

View file

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

View file

@ -78,6 +78,7 @@ dependencies {
implementation(projects.features.roomlist.api)
implementation(projects.services.analytics.api)
implementation(projects.services.analytics.compose)
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api)
implementation(libs.datetime)
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.matrix.api.MatrixClient
import io.element.android.libraries.push.api.PushService
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import javax.inject.Inject
@ -37,8 +38,11 @@ class DefaultClearCacheUseCase @Inject constructor(
private val ftueService: FtueService,
private val pushService: PushService,
private val seenInvitesStore: SeenInvitesStore,
private val activeRoomsHolder: ActiveRoomsHolder,
) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
// Active rooms should be disposed of before clearing the cache
activeRoomsHolder.clear(matrixClient.sessionId)
// Clear Matrix cache
matrixClient.clearCache()
// 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.libraries.matrix.api.core.SessionId
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.room.FakeJoinedRoom
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.value
import io.element.android.tests.testutils.testCoroutineDispatchers
@ -31,8 +34,10 @@ import org.robolectric.RobolectricTestRunner
class DefaultClearCacheUseCaseTest {
@Test
fun `execute clear cache should do all the expected tasks`() = runTest {
val activeRoomsHolder = ActiveRoomsHolder().apply { addRoom(FakeJoinedRoom()) }
val clearCacheLambda = lambdaRecorder<Unit> { }
val matrixClient = FakeMatrixClient(
sessionId = A_SESSION_ID,
clearCacheLambda = clearCacheLambda,
)
val defaultCacheService = DefaultCacheService()
@ -55,6 +60,7 @@ class DefaultClearCacheUseCaseTest {
ftueService = ftueService,
pushService = pushService,
seenInvitesStore = seenInvitesStore,
activeRoomsHolder = activeRoomsHolder,
)
defaultCacheService.clearedCacheEventFlow.test {
sut.invoke()
@ -64,6 +70,7 @@ class DefaultClearCacheUseCaseTest {
.with(value(matrixClient.sessionId), value(false))
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
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
sealed interface RoomCallState {
data object Unavailable : RoomCallState
data class StandBy(
val canStartCall: Boolean,
) : RoomCallState
@ -25,6 +27,7 @@ sealed interface RoomCallState {
}
fun RoomCallState.hasPermissionToJoin() = when (this) {
RoomCallState.Unavailable -> false
is StandBy -> canStartCall
is OnGoing -> canJoinCall
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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_SESSION_ID
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.encryption.FakeEncryptionService
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 {
val room = aJoinedRoom(
canInviteResult = { Result.success(false) },
canKickResult = { Result.success(false) },
canBanResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
)
@ -277,6 +280,8 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp"))
}
},
canBanResult = { Result.success(false) },
canKickResult = { Result.success(false) },
canInviteResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
)
@ -304,6 +309,8 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp"))
}
},
canKickResult = { Result.success(false) },
canBanResult = { Result.success(false) },
canInviteResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
getUpdatedMemberResult = { userId ->
@ -353,6 +360,8 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp"))
}
},
userDisplayNameResult = { Result.success(A_USER_NAME) },
userAvatarUrlResult = { Result.success(AN_AVATAR_URL) },
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
getUpdatedMemberResult = { userId ->
@ -397,6 +406,12 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp"))
}
},
canKickResult = {
Result.success(false)
},
canBanResult = {
Result.success(false)
},
canInviteResult = {
Result.success(false)
},
@ -424,6 +439,12 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp"))
}
},
canBanResult = {
Result.success(false)
},
canKickResult = {
Result.success(false)
},
canInviteResult = {
Result.success(false)
},
@ -450,6 +471,12 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp"))
}
},
canKickResult = {
Result.success(false)
},
canBanResult = {
Result.success(false)
},
canInviteResult = {
Result.success(false)
},
@ -476,6 +503,12 @@ class RoomDetailsPresenterTest {
else -> Result.failure(Throwable("Whelp"))
}
},
canKickResult = {
Result.success(false)
},
canBanResult = {
Result.success(false)
},
canInviteResult = {
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.alias.ResolvedRoomAlias
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.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
@ -289,7 +290,14 @@ class EditBaseRoomAddressPresenterTest {
val navigator = FakeSecurityAndPrivacyNavigator(
closeEditRoomAddressLambda = closeEditAddressLambda
)
val presenter = createEditRoomAddressPresenter(navigator = navigator)
val presenter = createEditRoomAddressPresenter(
navigator = navigator,
room = FakeJoinedRoom(
publishRoomAliasInRoomDirectoryResult = {
Result.failure(AN_EXCEPTION)
},
)
)
presenter.test {
with(awaitItem()) {
eventSink(EditRoomAddressEvents.RoomAddressChanged("valid"))
@ -313,7 +321,13 @@ class EditBaseRoomAddressPresenterTest {
@Test
fun `present - dismiss error`() = runTest {
val presenter = createEditRoomAddressPresenter()
val presenter = createEditRoomAddressPresenter(
room = FakeJoinedRoom(
publishRoomAliasInRoomDirectoryResult = {
Result.failure(AN_EXCEPTION)
},
)
)
presenter.test {
with(awaitItem()) {
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.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
@ -132,11 +131,10 @@ class ResetIdentityFlowManagerTest {
private fun TestScope.createFlowManager(
encryptionService: FakeEncryptionService = FakeEncryptionService(),
client: FakeMatrixClient = FakeMatrixClient(encryptionService = encryptionService),
sessionCoroutineScope: CoroutineScope = this,
sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
) = ResetIdentityFlowManager(
matrixClient = client,
sessionCoroutineScope = sessionCoroutineScope,
sessionCoroutineScope = this,
sessionVerificationService = sessionVerificationService,
)
}

View file

@ -37,6 +37,7 @@ dependencies {
implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(projects.services.appnavstate.api)
api(libs.statemachine)
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.matrix.api.MatrixClient
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.MediaSender
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -33,6 +35,7 @@ class SharePresenter @AssistedInject constructor(
private val matrixClient: MatrixClient,
private val mediaPreProcessor: MediaPreProcessor,
private val sessionPreferencesStore: SessionPreferencesStore,
private val activeRoomsHolder: ActiveRoomsHolder,
) : Presenter<ShareState> {
@AssistedFactory
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(
intent: Intent,
roomIds: List<RoomId>,
@ -72,7 +81,7 @@ class SharePresenter @AssistedInject constructor(
} else {
roomIds
.map { roomId ->
val room = matrixClient.getJoinedRoom(roomId) ?: return@map false
val room = getJoinedRoom(roomId) ?: return@map false
val mediaSender = MediaSender(
preProcessor = mediaPreProcessor,
room = room,
@ -86,7 +95,11 @@ class SharePresenter @AssistedInject constructor(
).isSuccess
}
.all { it }
.also { room.destroy() }
.also {
if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) {
room.destroy()
}
}
}
.all { it }
}
@ -94,7 +107,7 @@ class SharePresenter @AssistedInject constructor(
onPlainText = { text ->
roomIds
.map { roomId ->
matrixClient.getJoinedRoom(roomId)?.liveTimeline?.sendMessage(
getJoinedRoom(roomId)?.liveTimeline?.sendMessage(
body = text,
htmlBody = null,
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.test.FakeMediaPreProcessor
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.lambda.lambdaRecorder
import kotlinx.coroutines.test.TestScope
@ -163,7 +164,8 @@ class SharePresenterTest {
intent: Intent = Intent(),
shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(),
matrixClient: MatrixClient = FakeMatrixClient(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor()
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
): SharePresenter {
return SharePresenter(
intent = intent,
@ -171,7 +173,8 @@ class SharePresenterTest {
shareIntentHandler = shareIntentHandler,
matrixClient = matrixClient,
mediaPreProcessor = mediaPreProcessor,
InMemorySessionPreferencesStore(),
sessionPreferencesStore = InMemorySessionPreferencesStore(),
activeRoomsHolder = activeRoomsHolder,
)
}
}

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ constraintlayout = "2.2.1"
constraintlayout_compose = "1.1.1"
lifecycle = "2.9.0"
activity = "1.10.1"
media3 = "1.6.1"
media3 = "1.7.1"
camera = "1.4.2"
# Compose
@ -40,7 +40,7 @@ serialization_json = "1.8.1"
coil = "3.1.0"
showkase = "1.0.3"
appyx = "1.7.0"
sqldelight = "2.0.2"
sqldelight = "2.1.0"
wysiwyg = "2.38.3"
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" }
gms_google_services = "com.google.gms:google-services:4.4.2"
# 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" }
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" }
@ -169,7 +169,7 @@ jsoup = "org.jsoup:jsoup:1.20.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.1.0"
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_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
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" }
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
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"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
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"
# 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"
# main branch can be tested replacing the version with main-SNAPSHOT
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" }
anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" }
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"
dependencycheck = "org.owasp.dependencycheck:12.1.1"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }

View file

@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=61ad310d3c7d3e5da131b76bbf22b5a4c0786e9d892dae8c1658d4b484de3caa
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
distributionSha256Sum=845952a9d6afa783db70bb3b0effaae45ae5542ca2bb7929619e8af49cb634cf
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
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.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.UserId
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
data class NotificationData(
val sessionId: SessionId,
val eventId: EventId,
val threadId: ThreadId?,
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
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 {
// This should never be called, as it's only used for multi-process setups
error("retrieveSessionFromKeychain should never be called for Android")

View file

@ -152,7 +152,7 @@ class RustMatrixClient(
)
private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(innerSyncService)
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 encryptionService = RustEncryptionService(
client = innerClient,
@ -189,7 +189,6 @@ class RustMatrixClient(
private val roomMembershipObserver = RoomMembershipObserver()
private val roomFactory = RustRoomFactory(
innerClient = innerClient,
roomListService = roomListService,
innerRoomListService = innerRoomListService,
sessionId = sessionId,

View file

@ -72,8 +72,9 @@ class RustMatrixClientFactory @Inject constructor(
suspend fun create(client: Client): RustMatrixClient {
val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
client.setUtdDelegate(UtdTracker(analyticsService))
val syncService = client.syncService()
.withUtdHook(UtdTracker(analyticsService))
.withOfflineMode()
.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.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
@ -25,6 +26,7 @@ class NotificationMapper(
private val notificationContentMapper = NotificationContentMapper()
fun map(
sessionId: SessionId,
eventId: EventId,
roomId: RoomId,
notificationItem: NotificationItem
@ -35,6 +37,7 @@ class NotificationMapper(
activeMembersCount = item.roomInfo.joinedMembersCount.toInt(),
)
NotificationData(
sessionId = sessionId,
eventId = eventId,
// FIXME once the `NotificationItem` in the SDK returns the thread id
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.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.NotificationItemsRequest
import timber.log.Timber
class RustNotificationService(
private val sessionId: SessionId,
private val notificationClient: NotificationClient,
private val dispatchers: CoroutineDispatchers,
clock: SystemClock,
) : NotificationService {
private val notificationMapper: NotificationMapper = NotificationMapper(clock)
override suspend fun getNotification(
roomId: RoomId,
eventId: EventId,
): Result<NotificationData?> = withContext(dispatchers.io) {
override suspend fun getNotifications(
ids: Map<RoomId, List<EventId>>
): Result<Map<EventId, NotificationData>> = withContext(dispatchers.io) {
runCatching {
val item = notificationClient.getNotification(roomId.value, eventId.value)
item?.use {
notificationMapper.map(eventId, roomId, it)
val requests = ids.map { (roomId, eventIds) ->
NotificationItemsRequest(
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
import android.content.Context
import android.net.ConnectivityManager
import android.provider.Settings
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
@ -32,6 +34,13 @@ class DefaultProxyProvider @Inject constructor(
private val context: Context
) : ProxyProvider {
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)
?.also {
Timber.d("Using global proxy")

View file

@ -212,6 +212,7 @@ class JoinedRustRoom(
internalIdPrefix = internalIdPrefix,
dateDividerMode = dateDividerMode,
trackReadReceipts = trackReadReceipts,
reportUtds = true,
)
).let { innerTimeline ->
val mode = when (createTimelineParams) {
@ -416,7 +417,6 @@ class JoinedRustRoom(
RustWidgetDriver(
widgetSettings = widgetSettings,
room = innerRoom,
joinedRustRoom = this,
widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider {
override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities {
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.RoomId
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.timeline.runWithTimelineListenerRegistered
import kotlinx.coroutines.CancellationException
@ -49,10 +48,7 @@ class RoomContentForwarder(
val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content
?: throw ForwardEventException(toRoomIds)
val targetSlidingSyncRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val targetRooms = targetSlidingSyncRooms.map { slidingSyncRoom ->
slidingSyncRoom.use { it.fullRoomWithTimeline(null) }
}
val targetRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val failedForwardingTo = mutableSetOf<RoomId>()
targetRooms.parallelMap { room ->
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.awaitLoaded
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.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
@ -28,10 +27,12 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
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.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 java.util.concurrent.atomic.AtomicBoolean
import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService
@ -39,7 +40,6 @@ import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService
class RustRoomFactory(
private val sessionId: SessionId,
private val deviceId: DeviceId,
private val innerClient: Client,
private val notificationSettingsService: NotificationSettingsService,
private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
@ -79,16 +79,11 @@ class RustRoomFactory(
Timber.d("Room factory is destroyed, returning null for $roomId")
return@withContext null
}
val roomListItem = awaitRoomListItem(roomId) ?: return@withContext null
getBaseRoom(roomListItem)
val room = awaitRoomInRoomList(roomId) ?: return@withContext null
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 {
val initialRoomInfo = sdkRoom.roomInfo()
return RustBaseRoom(
@ -110,18 +105,27 @@ class RustRoomFactory(
Timber.d("Room factory is destroyed, returning null for $roomId")
return@withContext null
}
val roomListItem = awaitRoomListItem(roomId) ?: return@withContext null
val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withContext null
if (roomListItem.membership() == Membership.JOINED) {
// Init the live timeline in the SDK from the RoomListItem
val sdkRoom = roomListItem.fullRoomWithTimeline(eventFilters)
if (sdkRoom.membership() == Membership.JOINED) {
// Init the live timeline in the SDK from the Room
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(
JoinedRustRoom(
baseRoom = getBaseRoom(sdkRoom),
notificationSettingsService = notificationSettingsService,
roomContentForwarder = roomContentForwarder,
liveInnerTimeline = sdkRoom.timeline(),
liveInnerTimeline = timeline,
coroutineDispatchers = dispatchers,
systemClock = systemClock,
featureFlagService = featureFlagService,
@ -129,7 +133,7 @@ class RustRoomFactory(
)
} else {
val preview = try {
roomListItem.previewRoom(via = emptyList())
sdkRoom.previewRoom(via = emptyList())
} catch (e: Exception) {
Timber.e(e, "Failed to get room preview for $roomId")
return@withContext null
@ -138,7 +142,7 @@ class RustRoomFactory(
GetRoomResult.NotJoined(
NotJoinedRustRoom(
sessionId = sessionId,
localRoom = getBaseRoom(roomListItem),
localRoom = getBaseRoom(sdkRoom),
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? {
var roomListItem = innerRoomListService.roomOrNull(roomId.value)
if (roomListItem == null) {
private suspend fun awaitRoomInRoomList(roomId: RoomId): Room? {
var sdkRoom = innerRoomListService.roomOrNull(roomId.value)
if (sdkRoom == null) {
// ... otherwise, lets wait for the SS to load all rooms and check again.
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")
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.launchIn
import kotlinx.coroutines.flow.onEach
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
import org.matrix.rustcomponents.sdk.RoomListEntriesListener
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
import org.matrix.rustcomponents.sdk.RoomListInterface
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener
import org.matrix.rustcomponents.sdk.RoomListServiceInterface
@ -114,7 +114,7 @@ internal fun RoomListServiceInterface.syncIndicator(): Flow<RoomListServiceSyncI
)
}.buffer(Channel.UNLIMITED)
internal fun RoomListServiceInterface.roomOrNull(roomId: String): RoomListItem? {
internal fun RoomListServiceInterface.roomOrNull(roomId: String): Room? {
return tryOrNull(
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.impl.room.RoomInfoMapper
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
class RoomSummaryFactory(
private val roomMessageFactory: RoomMessageFactory = RoomMessageFactory(),
private val roomInfoMapper: RoomInfoMapper = RoomInfoMapper(),
) {
suspend fun create(roomListItem: RoomListItem): RoomSummary {
val roomInfo = roomListItem.roomInfo().let(roomInfoMapper::map)
val latestRoomMessage = roomListItem.latestEvent().use { event ->
suspend fun create(room: Room): RoomSummary {
val roomInfo = room.roomInfo().let(roomInfoMapper::map)
val latestRoomMessage = room.latestEvent().use { event ->
roomMessageFactory.create(event)
}
return RoomSummary(

View file

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

View file

@ -53,4 +53,5 @@ fun TracingConfiguration.map(): org.matrix.rustcomponents.sdk.TracingConfigurati
extraTargets = extraTargets,
traceLogPacks = traceLogPacks.map(),
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.MatrixWidgetSettings
import io.element.android.libraries.matrix.impl.room.JoinedRustRoom
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -25,7 +24,6 @@ import kotlin.coroutines.coroutineContext
class RustWidgetDriver(
widgetSettings: MatrixWidgetSettings,
private val room: Room,
private val joinedRustRoom: JoinedRustRoom,
private val widgetCapabilitiesProvider: WidgetCapabilitiesProvider,
) : MatrixWidgetDriver {
// 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() {
receiveMessageJob?.cancel()
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.SyncServiceBuilder
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate
class FakeRustClient(
private val userId: String = A_USER_ID.value,
@ -34,6 +35,7 @@ class FakeRustClient(
private val encryption: Encryption = FakeRustEncryption(),
private val session: Session = aRustSession(),
private val clearCachesResult: () -> Unit = { lambdaError() },
private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() },
private val closeResult: () -> Unit = {},
) : Client(NoPointer) {
override fun userId(): String = userId
@ -58,5 +60,6 @@ class FakeRustClient(
override suspend fun deletePusher(identifiers: PusherIdentifiers) = Unit
override suspend fun clearCaches() = simulateLongTask { clearCachesResult() }
override suspend fun setUtdDelegate(utdDelegate: UnableToDecryptDelegate) = withUtdHook(utdDelegate)
override fun close() = closeResult()
}

View file

@ -47,6 +47,6 @@ class FakeRustClientBuilder : ClientBuilder(NoPointer) {
}
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
import io.element.android.tests.testutils.simulateLongTask
import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationItem
import org.matrix.rustcomponents.sdk.NotificationItemsRequest
class FakeRustNotificationClient(
var notificationItemResult: NotificationItem? = null
var notificationItemResult: Map<String, NotificationItem> = emptyMap(),
) : NotificationClient(NoPointer) {
override suspend fun getNotification(roomId: String, eventId: String): NotificationItem? = simulateLongTask {
notificationItemResult
override suspend fun getNotifications(requests: List<NotificationItemsRequest>): Map<String, NotificationItem> {
return notificationItemResult
}
}

View file

@ -8,10 +8,13 @@
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 io.element.android.libraries.matrix.test.A_ROOM_ID
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.Room
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomMembersIterator
class FakeRustRoom(
@ -19,6 +22,8 @@ class FakeRustRoom(
private val getMembers: () -> RoomMembersIterator = { lambdaError() },
private val getMembersNoSync: () -> RoomMembersIterator = { lambdaError() },
private val leaveLambda: () -> Unit = { lambdaError() },
private val latestEventLambda: () -> EventTimelineItem? = { lambdaError() },
private val roomInfo: RoomInfo = aRustRoomInfo(id = roomId.value),
) : Room(NoPointer) {
override fun id(): String {
return roomId.value
@ -36,6 +41,14 @@ class FakeRustRoom(
leaveLambda()
}
override suspend fun roomInfo(): RoomInfo {
return roomInfo
}
override suspend fun latestEvent(): EventTimelineItem? {
return latestEventLambda()
}
override fun close() {
// 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.SyncService
import org.matrix.rustcomponents.sdk.SyncServiceBuilder
import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate
class FakeRustSyncServiceBuilder : SyncServiceBuilder(NoPointer) {
override suspend fun withUtdHook(delegate: UnableToDecryptDelegate): SyncServiceBuilder = this
override fun withOfflineMode(): SyncServiceBuilder = this
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.A_MESSAGE
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.services.toolbox.api.systemclock.SystemClock
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
@ -28,12 +29,12 @@ class RustNotificationServiceTest {
@Test
fun test() = runTest {
val notificationClient = FakeRustNotificationClient(
notificationItemResult = aRustNotificationItem(),
notificationItemResult = mapOf(AN_EVENT_ID.value to aRustNotificationItem()),
)
val sut = createRustNotificationService(
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.content).isEqualTo(
NotificationContent.MessageLike.RoomMessage(
@ -51,6 +52,7 @@ class RustNotificationServiceTest {
clock: SystemClock = FakeSystemClock(),
) =
RustNotificationService(
sessionId = A_SESSION_ID,
notificationClient = notificationClient,
dispatchers = testCoroutineDispatchers(),
clock = clock,

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.room
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
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.room.aRoomInfo
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.isActive
@ -30,10 +30,7 @@ import org.junit.Test
class RustBaseRoomTest {
@Test
fun `RustBaseRoom should cancel the room coroutine scope when it is destroyed`() = runTest {
val rustBaseRoom = createRustBaseRoom(
// Not using backgroundScope here, but the test scope
sessionCoroutineScope = this
)
val rustBaseRoom = createRustBaseRoom()
assertThat(rustBaseRoom.roomCoroutineScope.isActive).isTrue()
rustBaseRoom.destroy()
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 {
val roomMembershipObserver = RoomMembershipObserver()
val rustBaseRoom = createRustBaseRoom(
sessionCoroutineScope = this,
initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.JOINED),
innerRoom = FakeRustRoom(
leaveLambda = {
@ -52,23 +48,18 @@ class RustBaseRoomTest {
),
roomMembershipObserver = roomMembershipObserver,
)
val shared = roomMembershipObserver.updates.shareIn(scope = backgroundScope, started = SharingStarted.Eagerly, replay = 1)
rustBaseRoom.leave()
shared.test {
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.LEFT)
ensureAllEventsConsumed()
}
rustBaseRoom.destroy()
}
@Test
fun `when currentUserMembership=KNOCKED and user leave room succeed then roomMembershipObserver emits change as KNOCK_RETRACTED`() = runTest {
val roomMembershipObserver = RoomMembershipObserver()
val rustBaseRoom = createRustBaseRoom(
sessionCoroutineScope = this,
initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.KNOCKED),
innerRoom = FakeRustRoom(
leaveLambda = {
@ -77,23 +68,18 @@ class RustBaseRoomTest {
),
roomMembershipObserver = roomMembershipObserver,
)
val shared = roomMembershipObserver.updates.shareIn(scope = backgroundScope, started = SharingStarted.Eagerly, replay = 1)
rustBaseRoom.leave()
shared.test {
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.KNOCK_RETRACTED)
ensureAllEventsConsumed()
}
rustBaseRoom.destroy()
}
@Test
fun `when currentUserMembership=INVITED and user leave room succeed then roomMembershipObserver emits change as INVITATION_REJECTED`() = runTest {
val roomMembershipObserver = RoomMembershipObserver()
val rustBaseRoom = createRustBaseRoom(
sessionCoroutineScope = this,
initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED),
innerRoom = FakeRustRoom(
leaveLambda = {
@ -102,39 +88,44 @@ class RustBaseRoomTest {
),
roomMembershipObserver = roomMembershipObserver,
)
val shared = roomMembershipObserver.updates.shareIn(scope = backgroundScope, started = SharingStarted.Eagerly, replay = 1)
rustBaseRoom.leave()
shared.test {
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.INVITATION_REJECTED)
ensureAllEventsConsumed()
}
rustBaseRoom.destroy()
}
@Test
fun `when user leave room fails then roomMembershipObserver emits nothing`() = runTest {
val roomMembershipObserver = RoomMembershipObserver()
val rustBaseRoom = createRustBaseRoom(
sessionCoroutineScope = this,
initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED),
innerRoom = FakeRustRoom(
leaveLambda = { error("Leave failed") }
),
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)
rustBaseRoom.leave()
shared.test {
validate()
ensureAllEventsConsumed()
}
rustBaseRoom.destroy()
}
private fun TestScope.createRustBaseRoom(
sessionCoroutineScope: CoroutineScope,
initialRoomInfo: RoomInfo = aRoomInfo(),
innerRoom: FakeRustRoom = FakeRustRoom(),
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
@ -150,7 +141,8 @@ class RustBaseRoomTest {
dispatchers = dispatchers,
),
roomMembershipObserver = roomMembershipObserver,
sessionCoroutineScope = sessionCoroutineScope,
// Not using backgroundScope here, but the test scope
sessionCoroutineScope = this,
roomInfoMapper = RoomInfoMapper(),
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.fakes.FakeRustRoomDirectorySearch
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
@ -31,7 +30,6 @@ class RustBaseRoomDirectoryListTest {
val mapper = RoomDescriptionMapper()
val sut = createRustRoomDirectoryList(
roomDirectorySearch = roomDirectorySearch,
scope = backgroundScope,
)
// Let the mxCallback be ready
runCurrent()
@ -81,10 +79,9 @@ class RustBaseRoomDirectoryListTest {
private fun TestScope.createRustRoomDirectoryList(
roomDirectorySearch: RoomDirectorySearch = FakeRustRoomDirectorySearch(),
scope: CoroutineScope,
) = RustRoomDirectoryList(
inner = roomDirectorySearch,
coroutineScope = scope,
coroutineScope = backgroundScope,
coroutineContext = StandardTestDispatcher(testScheduler),
)
}

View file

@ -8,8 +8,9 @@
package io.element.android.libraries.matrix.impl.roomlist
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.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.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
@ -30,7 +31,7 @@ class RoomSummaryListProcessorTest {
summaries.value = listOf(aRoomSummary())
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))))
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 {
summaries.value = listOf(aRoomSummary())
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.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 {
summaries.value = listOf(aRoomSummary())
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.first().roomId).isEqualTo(A_ROOM_ID_2)
@ -63,7 +64,7 @@ class RoomSummaryListProcessorTest {
val processor = createProcessor()
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[index].roomId).isEqualTo(A_ROOM_ID_2)
@ -75,7 +76,7 @@ class RoomSummaryListProcessorTest {
val processor = createProcessor()
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[index].roomId).isEqualTo(A_ROOM_ID_2)
@ -163,12 +164,17 @@ class RoomSummaryListProcessorTest {
val processor = createProcessor()
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[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(
summaries,
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.room.RoomSyncSubscriber
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
@ -28,7 +27,6 @@ class RustBaseRoomListServiceTest {
fun `syncIndicator should emit the expected values`() = runTest {
val roomListService = FakeRustRoomListService()
val sut = createRustRoomListService(
sessionCoroutineScope = backgroundScope,
roomListService = roomListService,
)
// Give time for mxCallback to setup
@ -44,18 +42,17 @@ class RustBaseRoomListServiceTest {
}
private fun TestScope.createRustRoomListService(
sessionCoroutineScope: CoroutineScope,
roomListService: RustRoomListService = FakeRustRoomListService(),
) = RustRoomListService(
innerRoomListService = roomListService,
sessionDispatcher = StandardTestDispatcher(testScheduler),
roomListFactory = RoomListFactory(
innerRoomListService = roomListService,
sessionCoroutineScope = sessionCoroutineScope,
sessionCoroutineScope = backgroundScope,
),
roomSyncSubscriber = RoomSyncSubscriber(
roomListService = roomListService,
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.lambdaRecorder
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -38,7 +37,6 @@ class TimelineItemsSubscriberTest {
MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE)
val timeline = FakeRustTimeline()
val timelineItemsSubscriber = createTimelineItemsSubscriber(
coroutineScope = backgroundScope,
timeline = timeline,
timelineItems = timelineItems,
)
@ -59,7 +57,6 @@ class TimelineItemsSubscriberTest {
MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE)
val timeline = FakeRustTimeline()
val timelineItemsSubscriber = createTimelineItemsSubscriber(
coroutineScope = backgroundScope,
timeline = timeline,
timelineItems = timelineItems,
)
@ -81,7 +78,6 @@ class TimelineItemsSubscriberTest {
val timeline = FakeRustTimeline()
val onNewSyncedEventRecorder = lambdaRecorder<Unit> { }
val timelineItemsSubscriber = createTimelineItemsSubscriber(
coroutineScope = backgroundScope,
timeline = timeline,
timelineItems = timelineItems,
onNewSyncedEvent = onNewSyncedEventRecorder,
@ -109,9 +105,7 @@ class TimelineItemsSubscriberTest {
@Test
fun `multiple subscriptions does not have side effect`() = runTest {
val timelineItemsSubscriber = createTimelineItemsSubscriber(
coroutineScope = backgroundScope,
)
val timelineItemsSubscriber = createTimelineItemsSubscriber()
timelineItemsSubscriber.subscribeIfNeeded()
timelineItemsSubscriber.subscribeIfNeeded()
timelineItemsSubscriber.unsubscribeIfNeeded()
@ -120,7 +114,6 @@ class TimelineItemsSubscriberTest {
}
private fun TestScope.createTimelineItemsSubscriber(
coroutineScope: CoroutineScope,
timeline: Timeline = FakeRustTimeline(),
timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> = MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE),
initLatch: CompletableDeferred<Unit> = CompletableDeferred(),
@ -128,7 +121,7 @@ private fun TestScope.createTimelineItemsSubscriber(
onNewSyncedEvent: () -> Unit = { lambdaError() },
): TimelineItemsSubscriber {
return TimelineItemsSubscriber(
timelineCoroutineScope = coroutineScope,
timelineCoroutineScope = backgroundScope,
dispatcher = StandardTestDispatcher(testScheduler),
timeline = timeline,
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_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_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true)
val A_ROOM_NOTIFICATION_MODE = RoomNotificationMode.MUTE

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