diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index 3cb3d46111..cfb743934f 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -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: diff --git a/.maestro/allTests.yaml b/.maestro/allTests.yaml index 927a1cb0e5..ecbde4dab9 100644 --- a/.maestro/allTests.yaml +++ b/.maestro/allTests.yaml @@ -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 diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml index 617df92e50..f3f584ef76 100644 --- a/.maestro/tests/account/login.yaml +++ b/.maestro/tests/account/login.yaml @@ -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} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cd484c95b0..f233620cdb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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}")) + } + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index 094009f3d3..c6b42e1d11 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -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( 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) } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinBaseRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt similarity index 68% rename from appnav/src/test/kotlin/io/element/android/appnav/JoinBaseRoomLoadedFlowNodeTest.kt rename to appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt index bb15ed6bb9..15175303dd 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/JoinBaseRoomLoadedFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt @@ -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, 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() + } } diff --git a/enterprise b/enterprise index d3dffc97bf..4a07c862a2 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit d3dffc97bf8b39386ace2db7d857bbff05c73c18 +Subproject commit 4a07c862a23a9fd1418eabf132cf9d6b25ea4927 diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt index 7156a3e603..8550f06f3d 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt @@ -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 { @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 } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt index 6f6bd9473c..dd4de7abb5 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt @@ -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 = 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 diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt index 43a1e7ceba..ef571e12b3 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -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, ) } } diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt index 2059e9836e..84084c38fe 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt @@ -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(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 { _, _, _ -> } 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 { _, _, _ -> } - 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(relaxed = true) val addMissedCallNotificationLambda = lambdaRecorder { _, _, _ -> } - 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(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(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(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, diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt index cd81533a47..a93dd5f91c 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt @@ -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, ) } diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt index 3209fb4631..d15586038b 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt @@ -16,9 +16,21 @@ interface EnterpriseService { fun defaultHomeserverList(): List 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) + } } diff --git a/features/enterprise/impl/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index 7f4f4b387b..3b0995c81e 100644 --- a/features/enterprise/impl/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -25,6 +25,8 @@ class DefaultEnterpriseService @Inject constructor() : EnterpriseService { override fun defaultHomeserverList(): List = emptyList() override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true + override suspend fun isElementCallAvailable(): Boolean = true + override fun semanticColorsLight(): SemanticColors = compoundColorsLight override fun semanticColorsDark(): SemanticColors = compoundColorsDark diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt index 14195e1243..bc90c77ada 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -18,6 +18,7 @@ class FakeEnterpriseService( private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() }, private val defaultHomeserverListResult: () -> List = { 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() } diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt index feabcf35b2..45af2682e1 100644 --- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt @@ -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, ) diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt index cea9fe29f5..5fdf1342ea 100644 --- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt @@ -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 {} 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), ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index 9efce719d8..fb21367501 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -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(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(buildContext, listOf(callback)) + } NavTarget.QrCode -> { createNode(buildContext) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt index 23d60abd01..9ebc246e25 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt @@ -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 = MutableStateFlow( defaultAccountProvider diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt new file mode 100644 index 0000000000..92fda4cf14 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt @@ -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 = { }, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt index 75a6127d9f..a5f0fd7d3b 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt @@ -23,10 +23,14 @@ open class AccountProviderProvider : PreviewParameterProvider { 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, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt index 091f2e6e02..b61f0283a7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt @@ -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( diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt index 2d401d45aa..459f45a06c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt @@ -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, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt index 590be0d6a7..bb3da316b1 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt @@ -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, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt index ecd2b8a3d6..e8e1f21cd8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt @@ -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, + val canSearchForAccountProviders: Boolean, val changeServerState: ChangeServerState, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt index d061303f3a..435eee7f89 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt @@ -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 { override val values: Sequence get() = sequenceOf( aChangeAccountProviderState(), + aChangeAccountProviderState(canSearchForAccountProviders = false), // Add other state here ) } -fun aChangeAccountProviderState() = ChangeAccountProviderState( - accountProviders = listOf( +fun aChangeAccountProviderState( + accountProviders: List = listOf( anAccountProvider() ), - changeServerState = aChangeServerState(), + canSearchForAccountProviders: Boolean = true, + changeServerState: ChangeServerState = aChangeServerState(), +) = ChangeAccountProviderState( + accountProviders = accountProviders, + canSearchForAccountProviders = canSearchForAccountProviders, + changeServerState = changeServerState, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt index ad2befb628..c4542dfb0d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt @@ -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( diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderEvents.kt new file mode 100644 index 0000000000..76c86d18c2 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderEvents.kt @@ -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 +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt new file mode 100644 index 0000000000..2189252d01 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt @@ -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, + 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().forEach { it.onOidcDetails(oidcDetails) } + } + + private fun onLoginPasswordNeeded() { + plugins().forEach { it.onLoginPasswordNeeded() } + } + + private fun onCreateAccountContinue(url: String) { + plugins().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, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt new file mode 100644 index 0000000000..464e30936f --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt @@ -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 { + @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, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderState.kt new file mode 100644 index 0000000000..3591334047 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderState.kt @@ -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, + val selectedAccountProvider: AccountProvider?, + val loginMode: AsyncData, + val eventSink: (ChooseAccountProviderEvents) -> Unit, +) { + val submitEnabled: Boolean + get() = selectedAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateProvider.kt new file mode 100644 index 0000000000..b921fee330 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateProvider.kt @@ -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 { + 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 + 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 = listOf( + anAccountProvider() + ), + selectedAccountProvider: AccountProvider? = null, + loginMode: AsyncData = AsyncData.Uninitialized, + eventSink: (ChooseAccountProviderEvents) -> Unit = {}, +) = ChooseAccountProviderState( + accountProviders = accountProviders, + selectedAccountProvider = selectedAccountProvider, + loginMode = loginMode, + eventSink = eventSink, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt new file mode 100644 index 0000000000..760e39ce85 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt @@ -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 = { }, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt index f17372b3ab..d9c1615fde 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt @@ -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().forEach { it.onSignIn() } + private fun onSignIn(mustChooseAccountProvider: Boolean) { + plugins().forEach { it.onSignIn(mustChooseAccountProvider) } } private fun onSignUp() { diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index 38ea9c3b49..f4696133fe 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -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 { @@ -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(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, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt index ded9ae7102..98484a1fc2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt @@ -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, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt index 975f9d2e7f..9be36719e2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt @@ -26,6 +26,7 @@ open class OnBoardingStateProvider : PreviewParameterProvider { 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, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt index dd41e38e64..99ab348b06 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -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) diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index 998a0bc775..4f4d98baaf 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -89,5 +89,6 @@ Try signing in manually, or scan the QR code with another device." "Matrix is an open network for secure, decentralised communication." "This is where your conversations will live — just like you would use an email provider to keep your emails." "You’re about to sign in to %1$s" + "Choose account provider" "You’re about to create an account on %1$s" diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt index a5ede23b2e..3d84a1da41 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt @@ -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()) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt index dbce8164c6..89abc6ddef 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt @@ -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() } } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt new file mode 100644 index 0000000000..98a185cac4 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt @@ -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, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt new file mode 100644 index 0000000000..a044874eed --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt @@ -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() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventSink = EventsRecorder(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() + 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() + rule.setChooseAccountProviderView( + state = aChooseAccountProviderState( + loginMode = AsyncData.Failure(AN_EXCEPTION), + eventSink = eventSink, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventSink.assertSingle(ChooseAccountProviderEvents.ClearError) + } + + private fun AndroidComposeTestRule.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, + ) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index 06034fef19..45be02b128 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -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, ) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt index 141b5c29ae..52af14cfe4 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt @@ -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 AndroidComposeTestRule.setOnboardingView( state: OnBoardingState, onSignInWithQrCode: () -> Unit = EnsureNeverCalled(), - onSignIn: () -> Unit = EnsureNeverCalled(), + onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(), onCreateAccount: () -> Unit = EnsureNeverCalled(), onReportProblem: () -> Unit = EnsureNeverCalled(), onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt index 098421f177..4bbccabc53 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt @@ -38,6 +38,9 @@ internal fun CallMenuItem( modifier: Modifier = Modifier, ) { when (roomCallState) { + RoomCallState.Unavailable -> { + Box(modifier) + } is RoomCallState.StandBy -> { StandByCallMenuItem( roomCallState = roomCallState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt index 41d9fa111b..1386a5a666 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt @@ -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 = {}, ) } - } } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 2dd7bca3c8..31065275b9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -513,7 +513,17 @@ class AttachmentsPreviewPresenterTest { @Test fun `present - dismissing the progress dialog stops media upload with media queue`() = runTest { val onDoneListenerResult = lambdaRecorder {} - 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 { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTest.kt index 2092d8d815..1b4caa8bdd 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTest.kt @@ -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, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 9146f6515b..5e669ca027 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -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, diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt index c20d608dff..17d270f5ec 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt @@ -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, diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 8d34d55559..397d0c7acb 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -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) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt index 8a081c0e41..5662e4fd46 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -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 diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt index 401477d5fc..ab391bfc62 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt @@ -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 { } 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() } } } diff --git a/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallState.kt b/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallState.kt index 13db67f447..725915b91a 100644 --- a/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallState.kt +++ b/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallState.kt @@ -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 } diff --git a/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallStateProvider.kt b/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallStateProvider.kt index cd83a06616..de3bc4ade8 100644 --- a/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallStateProvider.kt +++ b/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallStateProvider.kt @@ -16,6 +16,7 @@ open class RoomCallStateProvider : PreviewParameterProvider { anOngoingCallState(), anOngoingCallState(canJoinCall = false), anOngoingCallState(canJoinCall = true, isUserInTheCall = true), + RoomCallState.Unavailable, ) } diff --git a/features/roomcall/impl/build.gradle.kts b/features/roomcall/impl/build.gradle.kts index d4ed1e1fd4..0ec555bfb5 100644 --- a/features/roomcall/impl/build.gradle.kts +++ b/features/roomcall/impl/build.gradle.kts @@ -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) diff --git a/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt index 6df9f8103b..dbcf39766b 100644 --- a/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt +++ b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt @@ -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 { @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, diff --git a/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt b/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt index 9f99b9b1af..437a495934 100644 --- a/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt +++ b/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt @@ -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.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 }, + ), ) } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt index c08aa93692..8de8a52b0f 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt @@ -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) }, diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/editroomaddress/EditBaseRoomAddressPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/editroomaddress/EditBaseRoomAddressPresenterTest.kt index 7dfc3d464e..9b9cb4120c 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/editroomaddress/EditBaseRoomAddressPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/editroomaddress/EditBaseRoomAddressPresenterTest.kt @@ -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) diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManagerTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManagerTest.kt index 57a9fb6212..a26d6fabef 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManagerTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManagerTest.kt @@ -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, ) } diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts index ae0cad80df..c059a24982 100644 --- a/features/share/impl/build.gradle.kts +++ b/features/share/impl/build.gradle.kts @@ -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) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index 08e9bdc1ff..1c75daa828 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -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 { @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, @@ -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(), diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt index 3504390f68..07424aa384 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -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, ) } } diff --git a/features/userprofile/impl/build.gradle.kts b/features/userprofile/impl/build.gradle.kts index 70769c986d..4242efcb98 100644 --- a/features/userprofile/impl/build.gradle.kts +++ b/features/userprofile/impl/build.gradle.kts @@ -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) diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt index c098177529..237c57fecf 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt @@ -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 { @AssistedFactory interface Factory { @@ -59,11 +61,21 @@ class UserProfilePresenter @AssistedInject constructor( @Composable private fun getCanCall(roomId: RoomId?): State { - 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() } } } diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt index 76464b973c..d0369da660 100644 --- a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt +++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt @@ -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 = 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 ReceiveTurbine.awaitFirstItem(): T { - skipItems(1) + private suspend fun ReceiveTurbine.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 }, + ), ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a3f1399195..92a483368e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 247cf2a9f5..9128c7d428 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt index 5ac236ad80..338193ed44 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt @@ -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, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt index f229a46677..1e1c8b7fb7 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt @@ -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 + suspend fun getNotifications(ids: Map>): Result> } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt index 401798b4b1..cb1a443ccb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt @@ -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") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 5f1176b081..816e4f26bd 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -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, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index dae048a235..a88c9bab14 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -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() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt index 7fdb4a9fdc..9130df5c7a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt @@ -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, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt index dfc891ace8..7cf432bad4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt @@ -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 = withContext(dispatchers.io) { + override suspend fun getNotifications( + ids: Map> + ): Result> = 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") + } + } } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/proxy/DefaultProxyProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/proxy/DefaultProxyProvider.kt index 57f7cd8e6b..08809be5f7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/proxy/DefaultProxyProvider.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/proxy/DefaultProxyProvider.kt @@ -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()?.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") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index d01675bb29..cca8af2785 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -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) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt index 3a131e6a19..63f22dbf7d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt @@ -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() targetRooms.parallelMap { room -> room.use { targetRoom -> diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt index d76de248d0..e5a8ff4eb0 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt @@ -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 } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt index 9fddea7c4f..adcdfee9e1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt @@ -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 + suspend fun create(room: Room): RoomSummary { + val roomInfo = room.roomInfo().let(roomInfoMapper::map) + val latestRoomMessage = room.latestEvent().use { event -> roomMessageFactory.create(event) } return RoomSummary( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt index ac8992e23f..66931a1f13 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt @@ -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.() -> Unit) = withContext(coroutineContext) { mutex.withLock { val current = roomSummaries.replayCache.lastOrNull() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt index 07ee1dc1b9..912561d843 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt @@ -53,4 +53,5 @@ fun TracingConfiguration.map(): org.matrix.rustcomponents.sdk.TracingConfigurati extraTargets = extraTargets, traceLogPacks = traceLogPacks.map(), writeToFiles = writesToFilesConfiguration.toTracingFileConfiguration(), + sentryDsn = null, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt index b723ab194b..c80313f5da 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt @@ -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() } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClient.kt index 6489396260..583e397daa 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClient.kt @@ -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() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClientBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClientBuilder.kt index 2d72b8a4f5..eed26ba2dc 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClientBuilder.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClientBuilder.kt @@ -47,6 +47,6 @@ class FakeRustClientBuilder : ClientBuilder(NoPointer) { } override suspend fun build(): Client { - return FakeRustClient() + return FakeRustClient(withUtdHook = {}) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustNotificationClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustNotificationClient.kt index 1e607351b9..802f20c509 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustNotificationClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustNotificationClient.kt @@ -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 = emptyMap(), ) : NotificationClient(NoPointer) { - override suspend fun getNotification(roomId: String, eventId: String): NotificationItem? = simulateLongTask { - notificationItemResult + override suspend fun getNotifications(requests: List): Map { + return notificationItemResult } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustRoom.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustRoom.kt index 1e1146a038..e1652381ff 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustRoom.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustRoom.kt @@ -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 } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustRoomListItem.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustRoomListItem.kt deleted file mode 100644 index acc43a6b24..0000000000 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustRoomListItem.kt +++ /dev/null @@ -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 - } -} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustSyncServiceBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustSyncServiceBuilder.kt index 0ddfe16eea..e36f6f0b1f 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustSyncServiceBuilder.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustSyncServiceBuilder.kt @@ -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() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt index 71623337cd..c8d40d7332 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt @@ -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, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt index 990b3c29fa..6b2105eb2a 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt @@ -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.() -> 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, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryListTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryListTest.kt index ba3ee850c1..f7f0b3cae7 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryListTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryListTest.kt @@ -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), ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt index 08c61a8709..79bae7e37e 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt @@ -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(), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt index 0957a06700..24447d4d74 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt @@ -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, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt index ac15faea77..ca41fe40d7 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt @@ -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 { } 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> = MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE), initLatch: CompletableDeferred = 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), diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index 11a0257685..3a354df6b9 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -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 diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt index 38a8c79e17..4a9671f677 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt @@ -13,16 +13,13 @@ import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationService class FakeNotificationService : NotificationService { - private var getNotificationResult: Result = Result.success(null) + private var getNotificationsResult: Result> = Result.success(emptyMap()) - fun givenGetNotificationResult(result: Result) { - getNotificationResult = result + fun givenGetNotificationsResult(result: Result>) { + getNotificationsResult = result } - override suspend fun getNotification( - roomId: RoomId, - eventId: EventId, - ): Result { - return getNotificationResult + override suspend fun getNotifications(ids: Map>): Result> { + return getNotificationsResult } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt index 85473d9367..2bfd54954b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt @@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.A_USER_NAME_2 @@ -27,6 +28,7 @@ fun aNotificationData( roomDisplayName: String? = A_ROOM_NAME ): NotificationData { return NotificationData( + sessionId = A_SESSION_ID, eventId = AN_EVENT_ID, threadId = threadId, roomId = A_ROOM_ID, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt index 76e5403133..d755722bd3 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt @@ -93,7 +93,15 @@ class FakeBaseRoom( return powerLevelsResult() } - override fun destroy() = Unit + private var isDestroyed = false + + override fun destroy() { + isDestroyed = true + } + + fun assertDestroyed() { + check(isDestroyed) { "Room should be destroyed" } + } override suspend fun userDisplayName(userId: UserId): Result = simulateLongTask { userDisplayNameResult(userId) diff --git a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt index ae69d85b72..7b10e84816 100644 --- a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt +++ b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt @@ -37,7 +37,24 @@ class MediaSenderTest { @Test fun `given an attachment when sending it the preprocessor always runs`() = runTest { val preProcessor = FakeMediaPreProcessor() - val sender = createMediaSender(preProcessor) + val sender = createMediaSender( + preProcessor = preProcessor, + room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = lambdaRecorder< + File, + FileInfo, + String?, + String?, + ProgressCallback?, + ReplyParameters?, + Result, + > { _, _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + }, + ) + ) val uri = Uri.parse("content://image.jpg") sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index 982daec73e..6ee03ed418 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -11,10 +11,10 @@ import android.content.Context import android.net.Uri import androidx.core.content.FileProvider import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.EventId @@ -61,10 +61,14 @@ private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.No * this pattern allow decoupling between the object responsible of displaying notifications and the matrix sdk. */ interface NotifiableEventResolver { - suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): Result + suspend fun resolveEvents( + sessionId: SessionId, + notificationEventRequests: List + ): Result>> } @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) class DefaultNotifiableEventResolver @Inject constructor( private val stringProvider: StringProvider, private val clock: SystemClock, @@ -75,29 +79,34 @@ class DefaultNotifiableEventResolver @Inject constructor( private val callNotificationEventResolver: CallNotificationEventResolver, private val appPreferencesStore: AppPreferencesStore, ) : NotifiableEventResolver { - override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): Result { - // Restore session - val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return Result.failure( - ResolvingException("Unable to restore session for $sessionId") - ) - val notificationService = client.notificationService() - val notificationData = notificationService.getNotification( - roomId = roomId, - eventId = eventId, - ).onFailure { - Timber.tag(loggerTag.value).e(it, "Unable to resolve event: $eventId.") + override suspend fun resolveEvents( + sessionId: SessionId, + notificationEventRequests: List + ): Result>> { + Timber.d("Queueing notifications: $notificationEventRequests") + val client = matrixClientProvider.getOrRestore(sessionId).getOrElse { + return Result.failure(IllegalStateException("Couldn't get or restore client for session $sessionId")) } + val ids = notificationEventRequests.groupBy { it.roomId }.mapValues { (_, value) -> value.map { it.eventId } } // TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event - return notificationData.flatMap { - if (it == null) { - Timber.tag(loggerTag.value).d("No notification data found for event $eventId") - return@flatMap Result.failure(ResolvingException("Unable to resolve event $eventId")) - } else { - Timber.tag(loggerTag.value).d("Found notification item for $eventId") - it.asNotifiableEvent(client, sessionId) + val notifications = client.notificationService().getNotifications(ids).mapCatching { map -> + map.mapValues { (_, notificationData) -> + notificationData.asNotifiableEvent(client, sessionId) } } + + return Result.success( + notificationEventRequests.associate { + val notificationData = notifications.getOrNull()?.get(it.eventId) + if (notificationData != null) { + it to notificationData + } else { + // TODO once the SDK can actually return what went wrong, we should return it here instead of this generic error + it to Result.failure(ResolvingException("No notification data for ${it.roomId} - ${it.eventId}")) + } + } + ) } private suspend fun NotificationData.asNotifiableEvent( diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index 93e4bd85e5..f485ba2954 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -113,6 +113,11 @@ class DefaultNotificationDrawerManager @Inject constructor( renderEvents(listOf(notifiableEvent)) } + suspend fun onNotifiableEventsReceived(notifiableEvents: List) { + val eventsToNotify = notifiableEvents.filter { !it.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value) } + renderEvents(eventsToNotify) + } + /** * Clear all known message events for a [sessionId]. */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandler.kt index 03a9c717d8..1efe57ce06 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandler.kt @@ -30,8 +30,9 @@ class DefaultOnMissedCallNotificationHandler @Inject constructor( // Resolve the event and add a notification for it, at this point it should no longer be a ringing one val notificationData = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?.notificationService() - ?.getNotification(roomId, eventId) + ?.getNotifications(mapOf(roomId to listOf(eventId))) ?.getOrNull() + ?.get(eventId) ?: return val notifiableEvent = callNotificationEventResolver.resolveEvent( diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt index 9c899c9dfd..594575bc44 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope @@ -44,6 +45,7 @@ class NotificationBroadcastReceiverHandler @Inject constructor( private val onNotifiableEventReceived: OnNotifiableEventReceived, private val stringProvider: StringProvider, private val replyMessageExtractor: ReplyMessageExtractor, + private val activeRoomsHolder: ActiveRoomsHolder, ) { fun onReceive(intent: Intent) { val sessionId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_SESSION_ID)?.let(::SessionId) ?: return @@ -117,13 +119,15 @@ class NotificationBroadcastReceiverHandler @Inject constructor( return@launch } val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch - client.getJoinedRoom(roomId)?.let { room -> + val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId) ?: client.getJoinedRoom(roomId) + + room?.let { sendMatrixEvent( sessionId = sessionId, roomId = roomId, replyToEventId = replyToEventId, threadId = threadId, - room = room, + room = it, message = message, ) } @@ -159,7 +163,7 @@ class NotificationBroadcastReceiverHandler @Inject constructor( roomIsDm = room.isDm(), outGoingMessage = true, ) - onNotifiableEventReceived.onNotifiableEventReceived(notifiableMessageEvent) + onNotifiableEventReceived.onNotifiableEventsReceived(listOf(notifiableMessageEvent)) if (threadId != null && replyToEventId != null) { room.liveTimeline.replyMessage( @@ -177,9 +181,11 @@ class NotificationBroadcastReceiverHandler @Inject constructor( ) }.onFailure { Timber.e(it, "Failed to send smart reply message") - onNotifiableEventReceived.onNotifiableEventReceived( - notifiableMessageEvent.copy( - outGoingMessageFailed = true + onNotifiableEventReceived.onNotifiableEventsReceived( + listOf( + notifiableMessageEvent.copy( + outGoingMessageFailed = true + ) ) ) } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt index 09bfbb51ba..88e6021cc4 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt @@ -37,6 +37,7 @@ class DefaultNotificationDisplayer @Inject constructor( return false } notificationManager.notify(tag, id, notification) + Timber.d("Notifying with tag: $tag, id: $id") return true } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt new file mode 100644 index 0000000000..1009cc3d36 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt @@ -0,0 +1,101 @@ +/* + * 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.libraries.push.impl.notifications + +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +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.push.impl.notifications.model.ResolvedPushEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +/** + * This class is responsible for periodically batching notification requests and resolving them in a single call, + * so that we can avoid having to resolve each notification individually in the SDK. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@SingleIn(AppScope::class) +class NotificationResolverQueue @Inject constructor( + private val notifiableEventResolver: NotifiableEventResolver, + private val appCoroutineScope: CoroutineScope, +) { + companion object { + private const val BATCH_WINDOW_MS = 250L + } + private val requestQueue = Channel(capacity = 100) + + private var currentProcessingJob: Job? = null + + /** + * A flow that emits pairs of a list of notification event requests and a map of the resolved events. + * The map contains the original request as the key and the resolved event as the value. + */ + val results: SharedFlow, Map>>> = MutableSharedFlow() + + /** + * Enqueues a notification event request to be resolved. + * The request will be processed in batches, so it may not be resolved immediately. + * + * @param request The notification event request to enqueue. + */ + suspend fun enqueue(request: NotificationEventRequest) { + // Cancel previous processing job if it exists, acting as a debounce operation + Timber.d("Cancelling job: $currentProcessingJob") + currentProcessingJob?.cancel() + + // Enqueue the request and start a delayed processing job + requestQueue.send(request) + currentProcessingJob = processQueue() + Timber.d("Starting processing job for request: $request") + } + + private fun processQueue() = appCoroutineScope.launch(SupervisorJob()) { + delay(BATCH_WINDOW_MS.milliseconds) + + // If this job is still active (so this is the latest job), we launch a separate one that won't be cancelled when enqueueing new items + // to process the existing queued items. + appCoroutineScope.launch { + val groupedRequestsById = buildList { + while (!requestQueue.isEmpty) { + requestQueue.receiveCatching().getOrNull()?.let(this::add) + } + }.groupBy { it.sessionId } + + val sessionIds = groupedRequestsById.keys + for (sessionId in sessionIds) { + val requests = groupedRequestsById[sessionId].orEmpty() + Timber.d("Fetching notifications for $sessionId: $requests. Pending requests: ${!requestQueue.isEmpty}") + // Resolving the events in parallel should improve performance since each session id will query a different Client + launch { + // No need for a Mutex since the SDK already has one internally + val notifications = notifiableEventResolver.resolveEvents(sessionId, requests).getOrNull().orEmpty() + (results as MutableSharedFlow).emit(requests to notifications) + } + } + } + } +} + +data class NotificationEventRequest( + val sessionId: SessionId, + val roomId: RoomId, + val eventId: EventId, + val providerInfo: String, +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/ResolvedPushEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/ResolvedPushEvent.kt index 48e2af6e98..2363e38e1d 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/ResolvedPushEvent.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/ResolvedPushEvent.kt @@ -12,12 +12,22 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId sealed interface ResolvedPushEvent { - data class Event(val notifiableEvent: NotifiableEvent) : ResolvedPushEvent + val sessionId: SessionId + val roomId: RoomId + val eventId: EventId + + data class Event(val notifiableEvent: NotifiableEvent) : ResolvedPushEvent { + override val sessionId: SessionId = notifiableEvent.sessionId + override val roomId: RoomId = notifiableEvent.roomId + override val eventId: EventId = notifiableEvent.eventId + } data class Redaction( - val sessionId: SessionId, - val roomId: RoomId, + override val sessionId: SessionId, + override val roomId: RoomId, val redactedEventId: EventId, val reason: String?, - ) : ResolvedPushEvent + ) : ResolvedPushEvent { + override val eventId: EventId = redactedEventId + } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index f6b6ace866..19b060d36b 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -13,6 +13,7 @@ import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.push.impl.history.PushHistoryService import io.element.android.libraries.push.impl.history.onDiagnosticPush @@ -20,8 +21,10 @@ import io.element.android.libraries.push.impl.history.onInvalidPushReceived import io.element.android.libraries.push.impl.history.onSuccess import io.element.android.libraries.push.impl.history.onUnableToResolveEvent import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession -import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.NotificationEventRequest +import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.libraries.push.impl.test.DefaultTestPush @@ -30,17 +33,21 @@ import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag) +@SingleIn(AppScope::class) @ContributesBinding(AppScope::class) class DefaultPushHandler @Inject constructor( private val onNotifiableEventReceived: OnNotifiableEventReceived, private val onRedactedEventReceived: OnRedactedEventReceived, - private val notifiableEventResolver: NotifiableEventResolver, private val incrementPushDataStore: IncrementPushDataStore, private val userPushStoreFactory: UserPushStoreFactory, private val pushClientSecret: PushClientSecret, @@ -50,7 +57,85 @@ class DefaultPushHandler @Inject constructor( private val elementCallEntryPoint: ElementCallEntryPoint, private val notificationChannels: NotificationChannels, private val pushHistoryService: PushHistoryService, + private val resolverQueue: NotificationResolverQueue, + private val appCoroutineScope: CoroutineScope, ) : PushHandler { + init { + processPushEventResults() + } + + /** + * Process the push notification event results emitted by the [resolverQueue]. + */ + private fun processPushEventResults() { + resolverQueue.results + .map { (requests, resolvedEvents) -> + for (request in requests) { + // Log the result of the push notification event + val result = resolvedEvents[request] + if (result?.isSuccess == true) { + pushHistoryService.onSuccess( + providerInfo = request.providerInfo, + eventId = request.eventId, + roomId = request.roomId, + sessionId = request.sessionId, + comment = "Push handled successfully", + ) + } else { + pushHistoryService.onUnableToResolveEvent( + providerInfo = request.providerInfo, + eventId = request.eventId, + roomId = request.roomId, + sessionId = request.sessionId, + reason = "Push not handled", + ) + } + } + + val events = mutableListOf() + val redactions = mutableListOf() + + @Suppress("LoopWithTooManyJumpStatements") + for (result in resolvedEvents.values) { + val event = result.getOrNull() ?: continue + val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId) + val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first() + // If notifications are disabled for this session and device, we don't want to show the notification + // But if it's a ringing call, we want to show it anyway + val isRingingCall = (event as? ResolvedPushEvent.Event)?.notifiableEvent is NotifiableRingingCallEvent + if (!areNotificationsEnabled && !isRingingCall) continue + + // We categorise each result into either a NotifiableEvent or a Redaction + when (event) { + is ResolvedPushEvent.Event -> { + events.add(event.notifiableEvent) + } + is ResolvedPushEvent.Redaction -> { + redactions.add(event) + } + } + } + + // Process redactions of messages + if (redactions.isNotEmpty()) { + onRedactedEventReceived.onRedactedEventsReceived(redactions) + } + + // Find and process ringing call notifications separately + val (ringingCallEvents, nonRingingCallEvents) = events.partition { it is NotifiableRingingCallEvent } + for (ringingCallEvent in ringingCallEvents) { + Timber.tag(loggerTag.value).d("Ringing call event: $ringingCallEvent") + handleRingingCallEvent(ringingCallEvent as NotifiableRingingCallEvent) + } + + // Finally, process other notifications (messages, invites, generic notifications, etc.) + if (nonRingingCallEvents.isNotEmpty()) { + onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents) + } + } + .launchIn(appCoroutineScope) + } + /** * Called when message is received. * @@ -119,52 +204,17 @@ class DefaultPushHandler @Inject constructor( ) return } - notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId).fold( - onSuccess = { resolvedPushEvent -> - pushHistoryService.onSuccess( - providerInfo = providerInfo, - eventId = pushData.eventId, - roomId = pushData.roomId, - sessionId = userId, - comment = resolvedPushEvent.javaClass.simpleName, - ) - when (resolvedPushEvent) { - is ResolvedPushEvent.Event -> { - when (val notifiableEvent = resolvedPushEvent.notifiableEvent) { - is NotifiableRingingCallEvent -> { - Timber.tag(loggerTag.value).d("Notifiable event ${pushData.eventId} is ringing call: $notifiableEvent") - onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent) - handleRingingCallEvent(notifiableEvent) - } - else -> { - Timber.tag(loggerTag.value).d("Notifiable event ${pushData.eventId} is normal event: $notifiableEvent") - val userPushStore = userPushStoreFactory.getOrCreate(userId) - val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first() - if (areNotificationsEnabled) { - onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent) - } else { - Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.") - } - } - } - } - is ResolvedPushEvent.Redaction -> { - onRedactedEventReceived.onRedactedEventReceived(resolvedPushEvent) - } - } - }, - onFailure = { failure -> - Timber.tag(loggerTag.value).w(failure, "Unable to get a notification data") - pushHistoryService.onUnableToResolveEvent( - providerInfo = providerInfo, - eventId = pushData.eventId, - roomId = pushData.roomId, - sessionId = userId, - reason = failure.message ?: failure.javaClass.simpleName, - ) - } - ) + appCoroutineScope.launch { + val notificationEventRequest = NotificationEventRequest( + sessionId = userId, + roomId = pushData.roomId, + eventId = pushData.eventId, + providerInfo = providerInfo, + ) + Timber.d("Queueing notification: $notificationEventRequest") + resolverQueue.enqueue(notificationEventRequest) + } } catch (e: Exception) { Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt index 7269dbcff7..14cb3d8ac6 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt @@ -17,7 +17,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject interface OnNotifiableEventReceived { - fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) + fun onNotifiableEventsReceived(notifiableEvents: List) } @ContributesBinding(AppScope::class) @@ -26,12 +26,10 @@ class DefaultOnNotifiableEventReceived @Inject constructor( private val coroutineScope: CoroutineScope, private val syncOnNotifiableEvent: SyncOnNotifiableEvent, ) : OnNotifiableEventReceived { - override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + override fun onNotifiableEventsReceived(notifiableEvents: List) { coroutineScope.launch { - launch { syncOnNotifiableEvent(notifiableEvent) } - if (notifiableEvent !is NotifiableRingingCallEvent) { - defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent) - } + launch { syncOnNotifiableEvent(notifiableEvents) } + defaultNotificationDrawerManager.onNotifiableEventsReceived(notifiableEvents.filter { it !is NotifiableRingingCallEvent }) } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt index bf452bd3b7..8cf77b1898 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt @@ -29,7 +29,7 @@ import timber.log.Timber import javax.inject.Inject interface OnRedactedEventReceived { - fun onRedactedEventReceived(redaction: ResolvedPushEvent.Redaction) + fun onRedactedEventsReceived(redactions: List) } @ContributesBinding(AppScope::class) @@ -40,48 +40,54 @@ class DefaultOnRedactedEventReceived @Inject constructor( @ApplicationContext private val context: Context, private val stringProvider: StringProvider, ) : OnRedactedEventReceived { - override fun onRedactedEventReceived(redaction: ResolvedPushEvent.Redaction) { + override fun onRedactedEventsReceived(redactions: List) { coroutineScope.launch { - val notifications = activeNotificationsProvider.getMessageNotificationsForRoom( - redaction.sessionId, - redaction.roomId, - ) - if (notifications.isEmpty()) { - Timber.d("No notifications found for redacted event") + val redactionsBySessionIdAndRoom = redactions.groupBy { redaction -> + redaction.sessionId to redaction.roomId } - notifications.forEach { statusBarNotification -> - val notification = statusBarNotification.notification - val messagingStyle = MessagingStyle.extractMessagingStyleFromNotification(notification) - if (messagingStyle == null) { - Timber.w("Unable to retrieve messaging style from notification") - return@forEach + for ((keys, roomRedactions) in redactionsBySessionIdAndRoom) { + val (sessionId, roomId) = keys + val notifications = activeNotificationsProvider.getMessageNotificationsForRoom( + sessionId, + roomId, + ) + if (notifications.isEmpty()) { + Timber.d("No notifications found for redacted event") } - val messageToRedactIndex = messagingStyle.messages.indexOfFirst { message -> - message.extras.getString(DefaultNotificationCreator.MESSAGE_EVENT_ID) == redaction.redactedEventId.value - } - if (messageToRedactIndex == -1) { - Timber.d("Unable to find the message to remove from notification") - return@forEach - } - val oldMessage = messagingStyle.messages[messageToRedactIndex] - val content = buildSpannedString { - inSpans(StyleSpan(Typeface.ITALIC)) { - append(stringProvider.getString(CommonStrings.common_message_removed)) + notifications.forEach { statusBarNotification -> + val notification = statusBarNotification.notification + val messagingStyle = MessagingStyle.extractMessagingStyleFromNotification(notification) + if (messagingStyle == null) { + Timber.w("Unable to retrieve messaging style from notification") + return@forEach } + val messageToRedactIndex = messagingStyle.messages.indexOfFirst { message -> + roomRedactions.any { it.redactedEventId.value == message.extras.getString(DefaultNotificationCreator.MESSAGE_EVENT_ID) } + } + if (messageToRedactIndex == -1) { + Timber.d("Unable to find the message to remove from notification") + return@forEach + } + val oldMessage = messagingStyle.messages[messageToRedactIndex] + val content = buildSpannedString { + inSpans(StyleSpan(Typeface.ITALIC)) { + append(stringProvider.getString(CommonStrings.common_message_removed)) + } + } + val newMessage = MessagingStyle.Message( + content, + oldMessage.timestamp, + oldMessage.person + ) + messagingStyle.messages[messageToRedactIndex] = newMessage + notificationDisplayer.showNotificationMessage( + statusBarNotification.tag, + statusBarNotification.id, + NotificationCompat.Builder(context, notification) + .setStyle(messagingStyle) + .build() + ) } - val newMessage = MessagingStyle.Message( - content, - oldMessage.timestamp, - oldMessage.person - ) - messagingStyle.messages[messageToRedactIndex] = newMessage - notificationDisplayer.showNotificationMessage( - statusBarNotification.tag, - statusBarNotification.id, - NotificationCompat.Builder(context, notification) - .setStyle(messagingStyle) - .build() - ) } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt index 4c5eee260b..961b5ebf9d 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt @@ -8,15 +8,15 @@ package io.element.android.libraries.push.impl.push import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.parallelMap import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.AppForegroundStateService import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @@ -30,42 +30,44 @@ class SyncOnNotifiableEvent @Inject constructor( private val featureFlagService: FeatureFlagService, private val appForegroundStateService: AppForegroundStateService, private val dispatchers: CoroutineDispatchers, + private val activeRoomsHolder: ActiveRoomsHolder, ) { - suspend operator fun invoke(notifiableEvent: NotifiableEvent) = withContext(dispatchers.io) { - val isRingingCallEvent = notifiableEvent is NotifiableRingingCallEvent - if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush) && !isRingingCallEvent) { + suspend operator fun invoke(notifiableEvents: List) = withContext(dispatchers.io) { + if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) { return@withContext } - val client = matrixClientProvider.getOrRestore(notifiableEvent.sessionId).getOrNull() ?: return@withContext - client.getJoinedRoom(notifiableEvent.roomId)?.use { room -> - room.subscribeToSync() + try { + val eventsBySession = notifiableEvents.groupBy { it.sessionId } - // If the app is in foreground, sync is already running, so we just add the subscription above. - if (!appForegroundStateService.isInForeground.value) { - if (isRingingCallEvent) { - room.waitsUntilUserIsInTheCall(timeout = 60.seconds) - } else { - try { - appForegroundStateService.updateIsSyncingNotificationEvent(true) - room.waitsUntilEventIsKnown(eventId = notifiableEvent.eventId, timeout = 10.seconds) - } finally { - appForegroundStateService.updateIsSyncingNotificationEvent(false) + appForegroundStateService.updateIsSyncingNotificationEvent(true) + + for ((sessionId, events) in eventsBySession) { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: continue + val eventsByRoomId = events.groupBy { it.roomId } + + client.roomListService.subscribeToVisibleRooms(eventsByRoomId.keys.toList()) + + if (!appForegroundStateService.isInForeground.value) { + for ((roomId, eventsInRoom) in eventsByRoomId) { + val activeRoom = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId) + val room = activeRoom ?: client.getJoinedRoom(roomId) + + if (room != null) { + eventsInRoom.parallelMap { event -> + room.waitsUntilEventIsKnown(event.eventId, timeout = 10.seconds) + } + } + + if (room != null && activeRoom == null) { + // Destroy the room we just instantiated to reset its live timeline + room.destroy() + } } } } - } - } - - /** - * User can be in the call if they answer using another session. - * If the user does not join the call, the timeout will be reached. - */ - private suspend fun BaseRoom.waitsUntilUserIsInTheCall(timeout: Duration) { - withTimeoutOrNull(timeout) { - roomInfoFlow.first { - sessionId in it.activeRoomCallParticipants - } + } finally { + appForegroundStateService.updateIsSyncingNotificationEvent(false) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index cf9244166d..2ba00156c7 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.push.impl.notifications import android.content.Context import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.notification.CallNotifyType import io.element.android.libraries.matrix.api.notification.NotificationContent @@ -67,7 +68,7 @@ class DefaultNotifiableEventResolverTest { @Test fun `resolve event no session`() = runTest { val sut = createDefaultNotifiableEventResolver(notificationService = null) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val result = sut.resolveEvents(A_SESSION_ID, listOf(NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase"))) assertThat(result.isFailure).isTrue() } @@ -76,36 +77,31 @@ class DefaultNotifiableEventResolverTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.failure(AN_EXCEPTION) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result.isFailure).isTrue() - } - - @Test - fun `resolve event null`() = runTest { - val sut = createDefaultNotifiableEventResolver( - notificationResult = Result.success(null) - ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result.isFailure).isTrue() + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.isFailure).isTrue() } @Test fun `resolve event message text`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = TextMessageType(body = "Hello world", formatted = null) - ), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = TextMessageType(body = "Hello world", formatted = null) + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test @@ -113,292 +109,337 @@ class DefaultNotifiableEventResolverTest { fun `resolve event message with mention`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = TextMessageType(body = "Hello world", formatted = null) - ), - hasMention = true, + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = TextMessageType(body = "Hello world", formatted = null) + ), + hasMention = true, + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world", hasMentionOrReply = true) ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve HTML formatted event message text takes plain text version`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = TextMessageType( - body = "Hello world!", - formatted = FormattedBody( - body = "Hello world", - format = MessageFormat.HTML, + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = TextMessageType( + body = "Hello world!", + formatted = FormattedBody( + body = "Hello world", + format = MessageFormat.HTML, + ) ) - ) - ), + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve incorrectly formatted event message text uses fallback`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = TextMessageType( - body = "Hello world", - formatted = FormattedBody( - body = "???Hello world!???", - format = MessageFormat.UNKNOWN, + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = TextMessageType( + body = "Hello world", + formatted = FormattedBody( + body = "???Hello world!???", + format = MessageFormat.UNKNOWN, + ) ) - ) - ), + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve event message audio`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = AudioMessageType("Audio", null, null, MediaSource("url"), null) - ), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = AudioMessageType("Audio", null, null, MediaSource("url"), null) + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Audio") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve event message video`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = VideoMessageType("Video", null, null, MediaSource("url"), null) - ), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = VideoMessageType("Video", null, null, MediaSource("url"), null) + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Video") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve event message voice`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = VoiceMessageType("Voice", null, null, MediaSource("url"), null, null) - ), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = VoiceMessageType("Voice", null, null, MediaSource("url"), null, null) + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Voice message") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve event message image`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = ImageMessageType("Image", null, null, MediaSource("url"), null), - ), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = ImageMessageType("Image", null, null, MediaSource("url"), null), + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Image") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve event message sticker`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = StickerMessageType("Sticker", null, null, MediaSource("url"), null), - ), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = StickerMessageType("Sticker", null, null, MediaSource("url"), null), + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Sticker") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve event message file`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = FileMessageType("File", null, null, MediaSource("url"), null), - ), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = FileMessageType("File", null, null, MediaSource("url"), null), + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "File") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve event message location`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = LocationMessageType("Location", "geo:1,2", null), - ), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = LocationMessageType("Location", "geo:1,2", null), + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Location") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve event message notice`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = NoticeMessageType("Notice", null), - ), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = NoticeMessageType("Notice", null), + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Notice") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve event message emote`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomMessage( - senderId = A_USER_ID_2, - messageType = EmoteMessageType("is happy", null), - ), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = EmoteMessageType("is happy", null), + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "* Bob is happy") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve poll`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.Poll( - senderId = A_USER_ID_2, - question = "A question" - ), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.Poll( + senderId = A_USER_ID_2, + question = "A question" + ), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Poll: A question") ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve RoomMemberContent invite room`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.StateEvent.RoomMemberContent( - userId = A_USER_ID_2, - membershipState = RoomMembershipState.INVITE - ), - isDirect = false, + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.StateEvent.RoomMemberContent( + userId = A_USER_ID_2, + membershipState = RoomMembershipState.INVITE + ), + isDirect = false, + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result.getOrNull()).isNull() + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.getOrNull()).isNull() } @Test fun `resolve invite room`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.Invite( - senderId = A_USER_ID_2, - ), - isDirect = false, + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.Invite( + senderId = A_USER_ID_2, + ), + isDirect = false, + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( sessionId = A_SESSION_ID, @@ -417,22 +458,25 @@ class DefaultNotifiableEventResolverTest { isUpdated = false, ) ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve invite direct`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.Invite( - senderId = A_USER_ID_2, - ), - isDirect = true, + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.Invite( + senderId = A_USER_ID_2, + ), + isDirect = true, + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( sessionId = A_SESSION_ID, @@ -451,23 +495,26 @@ class DefaultNotifiableEventResolverTest { isUpdated = false, ) ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve invite direct, no display name`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.Invite( - senderId = A_USER_ID_2, - ), - isDirect = true, - senderDisplayName = null, + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.Invite( + senderId = A_USER_ID_2, + ), + isDirect = true, + senderDisplayName = null, + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( sessionId = A_SESSION_ID, @@ -486,23 +533,26 @@ class DefaultNotifiableEventResolverTest { isUpdated = false, ) ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve invite direct, ambiguous display name`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.Invite( - senderId = A_USER_ID_2, - ), - isDirect = false, - senderIsNameAmbiguous = true, + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.Invite( + senderId = A_USER_ID_2, + ), + isDirect = false, + senderIsNameAmbiguous = true, + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( sessionId = A_SESSION_ID, @@ -521,35 +571,37 @@ class DefaultNotifiableEventResolverTest { isUpdated = false, ) ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve RoomMemberContent other`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.StateEvent.RoomMemberContent( - userId = A_USER_ID_2, - membershipState = RoomMembershipState.JOIN + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.StateEvent.RoomMemberContent( + userId = A_USER_ID_2, + membershipState = RoomMembershipState.JOIN + ) ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result.getOrNull()).isNull() + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.getOrNull()).isNull() } @Test fun `resolve RoomEncrypted`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomEncrypted - ) + mapOf(AN_EVENT_ID to aNotificationData(content = NotificationContent.MessageLike.RoomEncrypted)) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( FallbackNotifiableEvent( sessionId = A_SESSION_ID, @@ -563,19 +615,22 @@ class DefaultNotifiableEventResolverTest { timestamp = A_FAKE_TIMESTAMP, ) ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve CallInvite`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.CallInvite(A_USER_ID_2), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.CallInvite(A_USER_ID_2), + ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( NotifiableMessageEvent( sessionId = A_SESSION_ID, @@ -601,7 +656,7 @@ class DefaultNotifiableEventResolverTest { isUpdated = false ) ) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test @@ -609,11 +664,13 @@ class DefaultNotifiableEventResolverTest { val callNotificationEventResolver = FakeCallNotificationEventResolver() val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.CallNotify( - A_USER_ID_2, - CallNotifyType.NOTIFY - ), + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.CallNotify( + A_USER_ID_2, + CallNotifyType.NOTIFY + ), + ) ) ), callNotificationEventResolver = callNotificationEventResolver, @@ -639,18 +696,21 @@ class DefaultNotifiableEventResolverTest { ) ) callNotificationEventResolver.resolveEventLambda = { _, _, _ -> Result.success(expectedResult.notifiableEvent) } - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve RoomRedaction`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomRedaction( - AN_EVENT_ID_2, - A_REDACTION_REASON, + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomRedaction( + AN_EVENT_ID_2, + A_REDACTION_REASON, + ) ) ) ) @@ -661,82 +721,91 @@ class DefaultNotifiableEventResolverTest { redactedEventId = AN_EVENT_ID_2, reason = A_REDACTION_REASON, ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result.getOrNull()).isEqualTo(expectedResult) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @Test fun `resolve RoomRedaction with null redactedEventId should return null`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.RoomRedaction( - null, - A_REDACTION_REASON, + mapOf( + AN_EVENT_ID to aNotificationData( + content = NotificationContent.MessageLike.RoomRedaction( + null, + A_REDACTION_REASON, + ) ) ) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result.isFailure).isTrue() + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.getOrNull()).isNull() } @Test fun `resolve null cases`() { - testFailure(NotificationContent.MessageLike.CallAnswer) - testFailure(NotificationContent.MessageLike.CallHangup) - testFailure(NotificationContent.MessageLike.CallCandidates) - testFailure(NotificationContent.MessageLike.KeyVerificationReady) - testFailure(NotificationContent.MessageLike.KeyVerificationStart) - testFailure(NotificationContent.MessageLike.KeyVerificationCancel) - testFailure(NotificationContent.MessageLike.KeyVerificationAccept) - testFailure(NotificationContent.MessageLike.KeyVerificationKey) - testFailure(NotificationContent.MessageLike.KeyVerificationMac) - testFailure(NotificationContent.MessageLike.KeyVerificationDone) - testFailure(NotificationContent.MessageLike.ReactionContent(relatedEventId = AN_EVENT_ID_2.value)) - testFailure(NotificationContent.MessageLike.Sticker) - testFailure(NotificationContent.StateEvent.PolicyRuleRoom) - testFailure(NotificationContent.StateEvent.PolicyRuleServer) - testFailure(NotificationContent.StateEvent.PolicyRuleUser) - testFailure(NotificationContent.StateEvent.RoomAliases) - testFailure(NotificationContent.StateEvent.RoomAvatar) - testFailure(NotificationContent.StateEvent.RoomCanonicalAlias) - testFailure(NotificationContent.StateEvent.RoomCreate) - testFailure(NotificationContent.StateEvent.RoomEncryption) - testFailure(NotificationContent.StateEvent.RoomGuestAccess) - testFailure(NotificationContent.StateEvent.RoomHistoryVisibility) - testFailure(NotificationContent.StateEvent.RoomJoinRules) - testFailure(NotificationContent.StateEvent.RoomName) - testFailure(NotificationContent.StateEvent.RoomPinnedEvents) - testFailure(NotificationContent.StateEvent.RoomPowerLevels) - testFailure(NotificationContent.StateEvent.RoomServerAcl) - testFailure(NotificationContent.StateEvent.RoomThirdPartyInvite) - testFailure(NotificationContent.StateEvent.RoomTombstone) - testFailure(NotificationContent.StateEvent.RoomTopic("")) - testFailure(NotificationContent.StateEvent.SpaceChild) - testFailure(NotificationContent.StateEvent.SpaceParent) + testNoResults(NotificationContent.MessageLike.CallAnswer) + testNoResults(NotificationContent.MessageLike.CallHangup) + testNoResults(NotificationContent.MessageLike.CallCandidates) + testNoResults(NotificationContent.MessageLike.KeyVerificationReady) + testNoResults(NotificationContent.MessageLike.KeyVerificationStart) + testNoResults(NotificationContent.MessageLike.KeyVerificationCancel) + testNoResults(NotificationContent.MessageLike.KeyVerificationAccept) + testNoResults(NotificationContent.MessageLike.KeyVerificationKey) + testNoResults(NotificationContent.MessageLike.KeyVerificationMac) + testNoResults(NotificationContent.MessageLike.KeyVerificationDone) + testNoResults(NotificationContent.MessageLike.ReactionContent(relatedEventId = AN_EVENT_ID_2.value)) + testNoResults(NotificationContent.MessageLike.Sticker) + testNoResults(NotificationContent.StateEvent.PolicyRuleRoom) + testNoResults(NotificationContent.StateEvent.PolicyRuleServer) + testNoResults(NotificationContent.StateEvent.PolicyRuleUser) + testNoResults(NotificationContent.StateEvent.RoomAliases) + testNoResults(NotificationContent.StateEvent.RoomAvatar) + testNoResults(NotificationContent.StateEvent.RoomCanonicalAlias) + testNoResults(NotificationContent.StateEvent.RoomCreate) + testNoResults(NotificationContent.StateEvent.RoomEncryption) + testNoResults(NotificationContent.StateEvent.RoomGuestAccess) + testNoResults(NotificationContent.StateEvent.RoomHistoryVisibility) + testNoResults(NotificationContent.StateEvent.RoomJoinRules) + testNoResults(NotificationContent.StateEvent.RoomName) + testNoResults(NotificationContent.StateEvent.RoomPinnedEvents) + testNoResults(NotificationContent.StateEvent.RoomPowerLevels) + testNoResults(NotificationContent.StateEvent.RoomServerAcl) + testNoResults(NotificationContent.StateEvent.RoomThirdPartyInvite) + testNoResults(NotificationContent.StateEvent.RoomTombstone) + testNoResults(NotificationContent.StateEvent.RoomTopic("")) + testNoResults(NotificationContent.StateEvent.SpaceChild) + testNoResults(NotificationContent.StateEvent.SpaceParent) } - private fun testFailure(content: NotificationContent) = runTest { + private fun testNoResults(content: NotificationContent) = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( - aNotificationData( - content = content - ) + mapOf(AN_EVENT_ID to aNotificationData(content = content)) ) ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result.isFailure).isTrue() + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.getOrNull()).isNull() + } + + private fun Result>>.getEvent( + request: NotificationEventRequest + ): Result? { + return getOrNull()?.get(request) } private fun createDefaultNotifiableEventResolver( notificationService: FakeNotificationService? = FakeNotificationService(), - notificationResult: Result = Result.success(null), + notificationResult: Result> = Result.success(emptyMap()), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(), ): DefaultNotifiableEventResolver { val context = RuntimeEnvironment.getApplication() as Context - notificationService?.givenGetNotificationResult(notificationResult) + notificationService?.givenGetNotificationsResult(notificationResult) val matrixClientProvider = FakeMatrixClientProvider(getClient = { if (notificationService == null) { Result.failure(IllegalStateException("Client not found")) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt index 6a4cd52430..1dc21a7ebe 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt @@ -42,7 +42,9 @@ class DefaultOnMissedCallNotificationHandlerTest { // Create a fake matrix client provider that returns a fake matrix client with a fake notification service that returns a valid notification data val matrixClientProvider = FakeMatrixClientProvider(getClient = { val notificationService = FakeNotificationService().apply { - givenGetNotificationResult(Result.success(aNotificationData(senderDisplayName = A_USER_NAME, senderIsNameAmbiguous = false))) + givenGetNotificationsResult( + Result.success(mapOf(AN_EVENT_ID to aNotificationData(senderDisplayName = A_USER_NAME, senderIsNameAmbiguous = false))) + ) } Result.success(FakeMatrixClient(notificationService = notificationService)) }) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt index 3e98f8f9e8..d38bc098a0 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt @@ -7,16 +7,18 @@ package io.element.android.libraries.push.impl.notifications -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.push.impl.notifications.model.ResolvedPushEvent import io.element.android.tests.testutils.lambda.lambdaError class FakeNotifiableEventResolver( - private val notifiableEventResult: (SessionId, RoomId, EventId) -> Result = { _, _, _ -> lambdaError() } + private val resolveEventsResult: (SessionId, List) -> Result>> = + { _, _ -> lambdaError() } ) : NotifiableEventResolver { - override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): Result { - return notifiableEventResult(sessionId, roomId, eventId) + override suspend fun resolveEvents( + sessionId: SessionId, + notificationEventRequests: List + ): Result>> { + return resolveEventsResult(sessionId, notificationEventRequests) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt index 8a4f535216..ac53bace79 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt @@ -41,6 +41,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableEven import io.element.android.libraries.push.impl.push.FakeOnNotifiableEventReceived import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.test.strings.FakeStringProvider @@ -353,8 +354,8 @@ class NotificationBroadcastReceiverHandlerTest { ) ) } - val onNotifiableEventReceivedResult = lambdaRecorder { _ -> } - val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceivedResult = onNotifiableEventReceivedResult) + val onNotifiableEventsReceivedResult = lambdaRecorder, Unit> { _ -> } + val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceivedResult = onNotifiableEventsReceivedResult) val sut = createNotificationBroadcastReceiverHandler( joinedRoom = joinedRoom, onNotifiableEventReceived = onNotifiableEventReceived, @@ -370,7 +371,7 @@ class NotificationBroadcastReceiverHandlerTest { sendMessage.assertions() .isCalledOnce() .with(value(A_MESSAGE), value(null), value(emptyList())) - onNotifiableEventReceivedResult.assertions() + onNotifiableEventsReceivedResult.assertions() .isCalledOnce() replyMessage.assertions() .isNeverCalled() @@ -420,8 +421,8 @@ class NotificationBroadcastReceiverHandlerTest { ) ) } - val onNotifiableEventReceivedResult = lambdaRecorder { _ -> } - val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceivedResult = onNotifiableEventReceivedResult) + val onNotifiableEventsReceivedResult = lambdaRecorder, Unit> { _ -> } + val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceivedResult = onNotifiableEventsReceivedResult) val sut = createNotificationBroadcastReceiverHandler( joinedRoom = joinedRoom, onNotifiableEventReceived = onNotifiableEventReceived, @@ -438,7 +439,7 @@ class NotificationBroadcastReceiverHandlerTest { runCurrent() sendMessage.assertions() .isNeverCalled() - onNotifiableEventReceivedResult.assertions() + onNotifiableEventsReceivedResult.assertions() .isCalledOnce() replyMessage.assertions() .isCalledOnce() @@ -477,6 +478,7 @@ class NotificationBroadcastReceiverHandlerTest { onNotifiableEventReceived: OnNotifiableEventReceived = FakeOnNotifiableEventReceived(), stringProvider: StringProvider = FakeStringProvider(), replyMessageExtractor: ReplyMessageExtractor = FakeReplyMessageExtractor(), + activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), ): NotificationBroadcastReceiverHandler { return NotificationBroadcastReceiverHandler( appCoroutineScope = this, @@ -494,6 +496,7 @@ class NotificationBroadcastReceiverHandlerTest { onNotifiableEventReceived = onNotifiableEventReceived, stringProvider = stringProvider, replyMessageExtractor = replyMessageExtractor, + activeRoomsHolder = activeRoomsHolder, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt index 2c8583bb61..1b08f7b14c 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt @@ -34,7 +34,7 @@ class DefaultOnRedactedEventReceivedTest { val sut = createDefaultOnRedactedEventReceived( getMessageNotificationsForRoomResult = { _, _ -> emptyList() } ) - sut.onRedactedEventReceived(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)) + sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null))) } @Test @@ -48,7 +48,7 @@ class DefaultOnRedactedEventReceivedTest { ) } ) - sut.onRedactedEventReceived(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)) + sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null))) } private fun TestScope.createDefaultOnRedactedEventReceived( diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt index 77b71c0c69..933c830525 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -31,6 +31,8 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.push.impl.history.FakePushHistoryService import io.element.android.libraries.push.impl.history.PushHistoryService import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.NotificationEventRequest +import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue import io.element.android.libraries.push.impl.notifications.ResolvingException import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent @@ -48,11 +50,15 @@ import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.Fa import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.matching import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Test import java.time.Instant +import kotlin.time.Duration.Companion.milliseconds private const val A_PUSHER_INFO = "info" @@ -80,10 +86,11 @@ class DefaultPushHandlerTest { fun `when classical PushData is received, the notification drawer is informed`() = runTest { val aNotifiableMessageEvent = aNotifiableMessageEvent() val notifiableEventResult = - lambdaRecorder> { _, _, _ -> - Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)) + lambdaRecorder, Result>>> { _, _, -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) } - val onNotifiableEventReceived = lambdaRecorder {} + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} val incrementPushCounterResult = lambdaRecorder {} val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } val pushHistoryService = FakePushHistoryService( @@ -96,8 +103,8 @@ class DefaultPushHandlerTest { clientSecret = A_SECRET, ) val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventReceived = onNotifiableEventReceived, - notifiableEventResult = notifiableEventResult, + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = notifiableEventResult, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), @@ -105,14 +112,17 @@ class DefaultPushHandlerTest { pushHistoryService = pushHistoryService, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + incrementPushCounterResult.assertions() .isCalledOnce() notifiableEventResult.assertions() .isCalledOnce() - .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) - onNotifiableEventReceived.assertions() + .with(value(A_USER_ID), any()) + onNotifiableEventsReceived.assertions() .isCalledOnce() - .with(value(aNotifiableMessageEvent)) + .with(value(listOf(aNotifiableMessageEvent))) onPushReceivedResult.assertions() .isCalledOnce() } @@ -122,10 +132,11 @@ class DefaultPushHandlerTest { runTest { val aNotifiableMessageEvent = aNotifiableMessageEvent() val notifiableEventResult = - lambdaRecorder> { _, _, _ -> - Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)) + lambdaRecorder, Result>>> { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) } - val onNotifiableEventReceived = lambdaRecorder {} + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} val incrementPushCounterResult = lambdaRecorder {} val aPushData = PushData( eventId = AN_EVENT_ID, @@ -138,8 +149,8 @@ class DefaultPushHandlerTest { onPushReceivedResult = onPushReceivedResult, ) val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventReceived = onNotifiableEventReceived, - notifiableEventResult = notifiableEventResult, + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = notifiableEventResult, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), @@ -150,11 +161,14 @@ class DefaultPushHandlerTest { pushHistoryService = pushHistoryService, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + incrementPushCounterResult.assertions() .isCalledOnce() notifiableEventResult.assertions() .isCalledOnce() - onNotifiableEventReceived.assertions() + onNotifiableEventsReceived.assertions() .isNeverCalled() onPushReceivedResult.assertions() .isCalledOnce() @@ -165,10 +179,11 @@ class DefaultPushHandlerTest { runTest { val aNotifiableMessageEvent = aNotifiableMessageEvent() val notifiableEventResult = - lambdaRecorder> { _, _, _ -> - Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)) + lambdaRecorder, Result>>> { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) } - val onNotifiableEventReceived = lambdaRecorder {} + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} val incrementPushCounterResult = lambdaRecorder {} val aPushData = PushData( eventId = AN_EVENT_ID, @@ -181,8 +196,8 @@ class DefaultPushHandlerTest { onPushReceivedResult = onPushReceivedResult, ) val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventReceived = onNotifiableEventReceived, - notifiableEventResult = notifiableEventResult, + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = notifiableEventResult, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { null } ), @@ -193,14 +208,17 @@ class DefaultPushHandlerTest { pushHistoryService = pushHistoryService, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + incrementPushCounterResult.assertions() .isCalledOnce() notifiableEventResult.assertions() .isCalledOnce() - .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) - onNotifiableEventReceived.assertions() + .with(value(A_USER_ID), any()) + onNotifiableEventsReceived.assertions() .isCalledOnce() - .with(value(aNotifiableMessageEvent)) + .with(value(listOf(aNotifiableMessageEvent))) onPushReceivedResult.assertions() .isCalledOnce() } @@ -210,10 +228,11 @@ class DefaultPushHandlerTest { runTest { val aNotifiableMessageEvent = aNotifiableMessageEvent() val notifiableEventResult = - lambdaRecorder> { _, _, _ -> - Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)) + lambdaRecorder, Result>>> { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) } - val onNotifiableEventReceived = lambdaRecorder {} + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} val incrementPushCounterResult = lambdaRecorder {} val aPushData = PushData( eventId = AN_EVENT_ID, @@ -226,8 +245,8 @@ class DefaultPushHandlerTest { onPushReceivedResult = onPushReceivedResult, ) val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventReceived = onNotifiableEventReceived, - notifiableEventResult = notifiableEventResult, + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = notifiableEventResult, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { null } ), @@ -242,7 +261,7 @@ class DefaultPushHandlerTest { .isCalledOnce() notifiableEventResult.assertions() .isNeverCalled() - onNotifiableEventReceived.assertions() + onNotifiableEventsReceived.assertions() .isNeverCalled() onPushReceivedResult.assertions() .isCalledOnce() @@ -252,10 +271,10 @@ class DefaultPushHandlerTest { fun `when classical PushData is received, but not able to resolve the event, nothing happen`() = runTest { val notifiableEventResult = - lambdaRecorder> { _, _, _ -> + lambdaRecorder, Result>>> { _, _ -> Result.failure(ResolvingException("Unable to resolve")) } - val onNotifiableEventReceived = lambdaRecorder {} + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} val incrementPushCounterResult = lambdaRecorder {} val aPushData = PushData( eventId = AN_EVENT_ID, @@ -268,8 +287,8 @@ class DefaultPushHandlerTest { onPushReceivedResult = onPushReceivedResult, ) val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventReceived = onNotifiableEventReceived, - notifiableEventResult = notifiableEventResult, + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = notifiableEventResult, buildMeta = aBuildMeta( // Also test `lowPrivacyLoggingEnabled = false` here lowPrivacyLoggingEnabled = false @@ -281,12 +300,15 @@ class DefaultPushHandlerTest { pushHistoryService = pushHistoryService, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + incrementPushCounterResult.assertions() .isCalledOnce() notifiableEventResult.assertions() .isCalledOnce() - .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) - onNotifiableEventReceived.assertions() + .with(value(A_USER_ID), any()) + onNotifiableEventsReceived.assertions() .isNeverCalled() onPushReceivedResult.assertions() .isCalledOnce() @@ -313,28 +335,38 @@ class DefaultPushHandlerTest { Unit, > { _, _, _, _, _, _, _, _ -> } val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) - val onNotifiableEventReceived = lambdaRecorder {} + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } val pushHistoryService = FakePushHistoryService( onPushReceivedResult = onPushReceivedResult, ) val defaultPushHandler = createDefaultPushHandler( elementCallEntryPoint = elementCallEntryPoint, - notifiableEventResult = { _, _, _ -> + notifiableEventsResult = { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) Result.success( - ResolvedPushEvent.Event(aNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli())) + mapOf( + request to Result.success( + ResolvedPushEvent.Event( + aNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli()) + ) + ) + ) ) }, incrementPushCounterResult = {}, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), - onNotifiableEventReceived = onNotifiableEventReceived, + onNotifiableEventsReceived = onNotifiableEventsReceived, pushHistoryService = pushHistoryService, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + handleIncomingCallLambda.assertions().isCalledOnce() - onNotifiableEventReceived.assertions().isCalledOnce() + onNotifiableEventsReceived.assertions().isNeverCalled() onPushReceivedResult.assertions().isCalledOnce() } @@ -346,7 +378,7 @@ class DefaultPushHandlerTest { unread = 0, clientSecret = A_SECRET, ) - val onNotifiableEventReceived = lambdaRecorder {} + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} val handleIncomingCallLambda = lambdaRecorder< CallType.RoomCall, EventId, @@ -365,9 +397,10 @@ class DefaultPushHandlerTest { ) val defaultPushHandler = createDefaultPushHandler( elementCallEntryPoint = elementCallEntryPoint, - onNotifiableEventReceived = onNotifiableEventReceived, - notifiableEventResult = { _, _, _ -> - Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.CALL_NOTIFY))) + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.CALL_NOTIFY))))) }, incrementPushCounterResult = {}, pushClientSecret = FakePushClientSecret( @@ -377,8 +410,10 @@ class DefaultPushHandlerTest { ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + advanceTimeBy(300.milliseconds) + handleIncomingCallLambda.assertions().isNeverCalled() - onNotifiableEventReceived.assertions().isCalledOnce() + onNotifiableEventsReceived.assertions().isCalledOnce() onPushReceivedResult.assertions().isCalledOnce() } @@ -390,7 +425,7 @@ class DefaultPushHandlerTest { unread = 0, clientSecret = A_SECRET, ) - val onNotifiableEventReceived = lambdaRecorder {} + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} val handleIncomingCallLambda = lambdaRecorder< CallType.RoomCall, EventId, @@ -409,9 +444,10 @@ class DefaultPushHandlerTest { ) val defaultPushHandler = createDefaultPushHandler( elementCallEntryPoint = elementCallEntryPoint, - onNotifiableEventReceived = onNotifiableEventReceived, - notifiableEventResult = { _, _, _ -> - Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent())) + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent())))) }, incrementPushCounterResult = {}, userPushStore = FakeUserPushStore().apply { @@ -423,8 +459,11 @@ class DefaultPushHandlerTest { pushHistoryService = pushHistoryService, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + handleIncomingCallLambda.assertions().isCalledOnce() - onNotifiableEventReceived.assertions().isCalledOnce() + onNotifiableEventsReceived.assertions().isNeverCalled() onPushReceivedResult.assertions().isCalledOnce() } @@ -442,26 +481,32 @@ class DefaultPushHandlerTest { redactedEventId = AN_EVENT_ID_2, reason = null ) - val onRedactedEventReceived = lambdaRecorder { } + val onRedactedEventReceived = lambdaRecorder, Unit> { } val incrementPushCounterResult = lambdaRecorder {} val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } val pushHistoryService = FakePushHistoryService( onPushReceivedResult = onPushReceivedResult, ) val defaultPushHandler = createDefaultPushHandler( - onRedactedEventReceived = onRedactedEventReceived, + onRedactedEventsReceived = onRedactedEventReceived, incrementPushCounterResult = incrementPushCounterResult, - notifiableEventResult = { _, _, _ -> Result.success(aRedaction) }, + notifiableEventsResult = { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(aRedaction))) + }, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), pushHistoryService = pushHistoryService, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + incrementPushCounterResult.assertions() .isCalledOnce() onRedactedEventReceived.assertions().isCalledOnce() - .with(value(aRedaction)) + .with(value(listOf(aRedaction))) onPushReceivedResult.assertions() .isCalledOnce() } @@ -493,10 +538,64 @@ class DefaultPushHandlerTest { .isCalledOnce() } - private fun createDefaultPushHandler( - onNotifiableEventReceived: (NotifiableEvent) -> Unit = { lambdaError() }, - onRedactedEventReceived: (ResolvedPushEvent.Redaction) -> Unit = { lambdaError() }, - notifiableEventResult: (SessionId, RoomId, EventId) -> Result = { _, _, _ -> lambdaError() }, + @Test + fun `when receiving several push notifications at the same time, those are batched before being processed`() = runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder, Result>>> { _, _, -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) + } + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val incrementPushCounterResult = lambdaRecorder {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val anotherPushData = PushData( + eventId = AN_EVENT_ID_2, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + defaultPushHandler.handle(anotherPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + + incrementPushCounterResult.assertions() + .isCalledExactly(2) + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), matching> { requests -> + requests.size == 2 && requests.first().eventId == AN_EVENT_ID && requests.last().eventId == AN_EVENT_ID_2 + }) + onNotifiableEventsReceived.assertions() + .isCalledOnce() + onPushReceivedResult.assertions() + .isCalledExactly(2) + } + + private fun TestScope.createDefaultPushHandler( + onNotifiableEventsReceived: (List) -> Unit = { lambdaError() }, + onRedactedEventsReceived: (List) -> Unit = { lambdaError() }, + notifiableEventsResult: (SessionId, List) -> Result>> = + { _, _, -> lambdaError() }, incrementPushCounterResult: () -> Unit = { lambdaError() }, userPushStore: UserPushStore = FakeUserPushStore(), pushClientSecret: PushClientSecret = FakePushClientSecret(), @@ -508,9 +607,8 @@ class DefaultPushHandlerTest { pushHistoryService: PushHistoryService = FakePushHistoryService(), ): DefaultPushHandler { return DefaultPushHandler( - onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceived), - onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventReceived), - notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventResult), + onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived), + onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventsReceived), incrementPushDataStore = object : IncrementPushDataStore { override suspend fun incrementPushCounter() { incrementPushCounterResult() @@ -524,6 +622,8 @@ class DefaultPushHandlerTest { elementCallEntryPoint = elementCallEntryPoint, notificationChannels = notificationChannels, pushHistoryService = pushHistoryService, + resolverQueue = NotificationResolverQueue(notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventsResult), backgroundScope), + appCoroutineScope = backgroundScope, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt index 0de4094809..055b1f322e 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt @@ -11,9 +11,9 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableEven import io.element.android.tests.testutils.lambda.lambdaError class FakeOnNotifiableEventReceived( - private val onNotifiableEventReceivedResult: (NotifiableEvent) -> Unit = { lambdaError() }, + private val onNotifiableEventsReceivedResult: (List) -> Unit = { lambdaError() }, ) : OnNotifiableEventReceived { - override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { - onNotifiableEventReceivedResult(notifiableEvent) + override fun onNotifiableEventsReceived(notifiableEvents: List) { + onNotifiableEventsReceivedResult(notifiableEvents) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnRedactedEventReceived.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnRedactedEventReceived.kt index 96e76f6da0..b5a3731830 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnRedactedEventReceived.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnRedactedEventReceived.kt @@ -11,9 +11,9 @@ import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEv import io.element.android.tests.testutils.lambda.lambdaError class FakeOnRedactedEventReceived( - private val onRedactedEventReceivedResult: (ResolvedPushEvent.Redaction) -> Unit = { lambdaError() }, + private val onRedactedEventsReceivedResult: (List) -> Unit = { lambdaError() }, ) : OnRedactedEventReceived { - override fun onRedactedEventReceived(redaction: ResolvedPushEvent.Redaction) { - onRedactedEventReceivedResult(redaction) + override fun onRedactedEventsReceived(redactions: List) { + onRedactedEventsReceivedResult(redactions) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt index 8b3907be0b..8ad5e28e6e 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -75,7 +76,7 @@ class SyncOnNotifiableEventTest { fun `when feature flag is disabled, nothing happens`() = runTest { val sut = createSyncOnNotifiableEvent(client = client, isSyncOnPushEnabled = false) - sut(notifiableEvent) + sut(listOf(notifiableEvent)) assert(startSyncLambda).isNeverCalled() assert(stopSyncLambda).isNeverCalled() @@ -96,7 +97,7 @@ class SyncOnNotifiableEventTest { unlocked.set(true) room.givenRoomInfo(aRoomInfo(hasRoomCall = true)) } - sut(incomingCallNotifiableEvent) + sut(listOf(incomingCallNotifiableEvent)) // The process was completed before the timeout assertThat(unlocked.get()).isTrue() @@ -116,30 +117,12 @@ class SyncOnNotifiableEventTest { unlocked.set(true) room.givenRoomInfo(aRoomInfo(hasRoomCall = true)) } - sut(incomingCallNotifiableEvent) + sut(listOf(incomingCallNotifiableEvent)) // Didn't unlock before the timeout assertThat(unlocked.get()).isFalse() } - @Test - fun `when feature flag is enabled and app is in foreground, sync is not started`() = runTest { - val appForegroundStateService = FakeAppForegroundStateService( - initialForegroundValue = true, - ) - val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true) - - appForegroundStateService.isSyncingNotificationEvent.test { - sut(notifiableEvent) - sut(incomingCallNotifiableEvent) - - // It's initially false - assertThat(awaitItem()).isFalse() - // It never becomes true - ensureAllEventsConsumed() - } - } - @Test fun `when feature flag is enabled and app is in background, sync is started and stopped`() = runTest { val appForegroundStateService = FakeAppForegroundStateService( @@ -153,7 +136,7 @@ class SyncOnNotifiableEventTest { appForegroundStateService.isSyncingNotificationEvent.test { syncService.emitSyncState(SyncState.Running) - sut(notifiableEvent) + sut(listOf(notifiableEvent)) // It's initially false assertThat(awaitItem()).isFalse() @@ -174,8 +157,8 @@ class SyncOnNotifiableEventTest { val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true) appForegroundStateService.isSyncingNotificationEvent.test { - launch { sut(notifiableEvent) } - launch { sut(notifiableEvent) } + launch { sut(listOf(notifiableEvent)) } + launch { sut(listOf(notifiableEvent)) } launch { delay(1) timelineItems.emit( @@ -199,7 +182,8 @@ class SyncOnNotifiableEventTest { isSyncOnPushEnabled: Boolean = true, appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService( initialForegroundValue = true, - ) + ), + activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), ): SyncOnNotifiableEvent { val featureFlagService = FakeFeatureFlagService( initialState = mapOf( @@ -212,6 +196,7 @@ class SyncOnNotifiableEventTest { featureFlagService = featureFlagService, appForegroundStateService = appForegroundStateService, dispatchers = testCoroutineDispatchers(), + activeRoomsHolder = activeRoomsHolder, ) } } diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts index 1dec445c00..c98d98f64e 100644 --- a/libraries/pushproviders/unifiedpush/build.gradle.kts +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -42,8 +42,7 @@ dependencies { implementation(libs.serialization.json) // UnifiedPush library - api(libs.unifiedpush) - + implementation(libs.unifiedpush) testImplementation(libs.coroutines.test) testImplementation(libs.test.junit) testImplementation(libs.test.robolectric) diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt index dc32dbff76..5df7c1e5c3 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt @@ -33,7 +33,7 @@ class DefaultRegisterUnifiedPushUseCase @Inject constructor( UnifiedPush.saveDistributor(context, distributor.value) // This will trigger the callback // VectorUnifiedPushMessagingReceiver.onNewEndpoint - UnifiedPush.registerApp(context = context, instance = clientSecret) + UnifiedPush.register(context = context, instance = clientSecret) // Wait for VectorUnifiedPushMessagingReceiver.onNewEndpoint to proceed return runCatching { withTimeout(30.seconds) { diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt index e41aa6a51f..24b9676c11 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt @@ -53,6 +53,6 @@ class DefaultUnregisterUnifiedPushUseCase @Inject constructor( override fun cleanup(clientSecret: String) { unifiedPushStore.storeUpEndpoint(clientSecret, null) unifiedPushStore.storePushGateway(clientSecret, null) - UnifiedPush.unregisterApp(context, clientSecret) + UnifiedPush.unregister(context, clientSecret) } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt index 684e08be5b..9cb7e8e60f 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -16,7 +16,10 @@ import io.element.android.libraries.pushproviders.unifiedpush.registration.Endpo import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import org.unifiedpush.android.connector.FailedReason import org.unifiedpush.android.connector.MessagingReceiver +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage import timber.log.Timber import javax.inject.Inject @@ -45,15 +48,15 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { * @param message the message * @param instance connection, for multi-account */ - override fun onMessage(context: Context, message: ByteArray, instance: String) { - Timber.tag(loggerTag.value).w("New message") + override fun onMessage(context: Context, message: PushMessage, instance: String) { + Timber.tag(loggerTag.value).d("New message, decrypted: ${message.decrypted}") coroutineScope.launch { - val pushData = pushParser.parse(message, instance) + val pushData = pushParser.parse(message.content, instance) if (pushData == null) { Timber.tag(loggerTag.value).w("Invalid data received from UnifiedPush") pushHandler.handleInvalid( providerInfo = "${UnifiedPushConfig.NAME} - $instance", - data = String(message), + data = String(message.content), ) } else { pushHandler.handle( @@ -68,20 +71,20 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { * Called when a new endpoint is to be used for sending push messages. * You should send the endpoint to your application server and sync for missing notifications. */ - override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) { Timber.tag(loggerTag.value).w("onNewEndpoint: $endpoint") coroutineScope.launch { - val gateway = unifiedPushGatewayResolver.getGateway(endpoint) + val gateway = unifiedPushGatewayResolver.getGateway(endpoint.url) .let { gatewayResult -> unifiedPushGatewayUrlResolver.resolve(gatewayResult, instance) } unifiedPushStore.storePushGateway(instance, gateway) - val result = newGatewayHandler.handle(endpoint, gateway, instance) + val result = newGatewayHandler.handle(endpoint.url, gateway, instance) .onFailure { Timber.tag(loggerTag.value).e(it, "Failed to handle new gateway") } .onSuccess { - unifiedPushStore.storeUpEndpoint(instance, endpoint) + unifiedPushStore.storeUpEndpoint(instance, endpoint.url) } endpointRegistrationHandler.registrationDone( RegistrationResult( @@ -96,8 +99,8 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { /** * Called when the registration is not possible, eg. no network. */ - override fun onRegistrationFailed(context: Context, instance: String) { - Timber.tag(loggerTag.value).e("onRegistrationFailed for $instance") + override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { + Timber.tag(loggerTag.value).e("onRegistrationFailed for $instance, reason: $reason") /* Toast.makeText(context, "Push service registration failed", Toast.LENGTH_SHORT).show() val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME @@ -110,7 +113,7 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { * Called when this application is unregistered from receiving push messages. */ override fun onUnregistered(context: Context, instance: String) { - Timber.tag(loggerTag.value).w("Unifiedpush: Unregistered") + Timber.tag(loggerTag.value).w("UnifiedPush: Unregistered") /* val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME pushDataStore.setFdroidSyncBackgroundMode(mode) diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt index bcacdc3d7a..5bb0a6731f 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt @@ -32,6 +32,10 @@ import org.junit.Assert.assertThrows import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.data.PublicKeySet +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage @RunWith(RobolectricTestRunner::class) class VectorUnifiedPushMessagingReceiverTest { @@ -56,7 +60,7 @@ class VectorUnifiedPushMessagingReceiverTest { fun `onRegistrationFailed does nothing`() = runTest { val context = InstrumentationRegistry.getInstrumentation().context val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver() - vectorUnifiedPushMessagingReceiver.onRegistrationFailed(context, A_SECRET) + vectorUnifiedPushMessagingReceiver.onRegistrationFailed(context, FailedReason.NETWORK, A_SECRET) } @Test @@ -68,7 +72,7 @@ class VectorUnifiedPushMessagingReceiverTest { handleResult = pushHandlerResult ), ) - vectorUnifiedPushMessagingReceiver.onMessage(context, UnifiedPushParserTest.UNIFIED_PUSH_DATA.toByteArray(), A_SECRET) + vectorUnifiedPushMessagingReceiver.onMessage(context, aPushMessage(), A_SECRET) advanceUntilIdle() pushHandlerResult.assertions() .isCalledOnce() @@ -96,7 +100,7 @@ class VectorUnifiedPushMessagingReceiverTest { handleInvalidResult = handleInvalidResult, ), ) - vectorUnifiedPushMessagingReceiver.onMessage(context, "".toByteArray(), A_SECRET) + vectorUnifiedPushMessagingReceiver.onMessage(context, aPushMessage(""), A_SECRET) advanceUntilIdle() handleInvalidResult.assertions().isCalledOnce() } @@ -127,7 +131,7 @@ class VectorUnifiedPushMessagingReceiverTest { unifiedPushNewGatewayHandler = unifiedPushNewGatewayHandler, ) endpointRegistrationHandler.state.test { - vectorUnifiedPushMessagingReceiver.onNewEndpoint(context, "anEndpoint", A_SECRET) + vectorUnifiedPushMessagingReceiver.onNewEndpoint(context, aPushEndpoint("anEndpoint"), A_SECRET) advanceUntilIdle() assertThat(awaitItem()).isEqualTo( RegistrationResult( @@ -170,7 +174,7 @@ class VectorUnifiedPushMessagingReceiverTest { unifiedPushNewGatewayHandler = unifiedPushNewGatewayHandler, ) endpointRegistrationHandler.state.test { - vectorUnifiedPushMessagingReceiver.onNewEndpoint(context, "anEndpoint", A_SECRET) + vectorUnifiedPushMessagingReceiver.onNewEndpoint(context, aPushEndpoint(), A_SECRET) advanceUntilIdle() assertThat(awaitItem()).isEqualTo( RegistrationResult( @@ -207,3 +211,19 @@ class VectorUnifiedPushMessagingReceiverTest { } } } + +private fun aPushMessage( + data: String = UnifiedPushParserTest.UNIFIED_PUSH_DATA, + decrypted: Boolean = true, +) = PushMessage( + content = data.toByteArray(), + decrypted = decrypted, +) + +private fun aPushEndpoint( + url: String = "anEndpoint", + pubKeySet: PublicKeySet? = null, +) = PushEndpoint( + url = url, + pubKeySet = pubKeySet, +) diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 95d3f501e4..d65fa5da90 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -48,25 +48,29 @@ class DatabaseSessionStore @Inject constructor( } } - override suspend fun storeData(sessionData: SessionData) = sessionDataMutex.withLock { - database.sessionDataQueries.insertSessionData(sessionData.toDbModel()) + override suspend fun storeData(sessionData: SessionData) { + sessionDataMutex.withLock { + database.sessionDataQueries.insertSessionData(sessionData.toDbModel()) + } } - override suspend fun updateData(sessionData: SessionData) = sessionDataMutex.withLock { - val result = database.sessionDataQueries.selectByUserId(sessionData.userId) - .executeAsOneOrNull() - ?.toApiModel() + override suspend fun updateData(sessionData: SessionData) { + sessionDataMutex.withLock { + val result = database.sessionDataQueries.selectByUserId(sessionData.userId) + .executeAsOneOrNull() + ?.toApiModel() - if (result == null) { - Timber.e("User ${sessionData.userId} not found in session database") - return - } + if (result == null) { + Timber.e("User ${sessionData.userId} not found in session database") + return + } // Copy new data from SDK, but keep login timestamp - database.sessionDataQueries.updateSession( - sessionData.copy( - loginTimestamp = result.loginTimestamp, - ).toDbModel() - ) + database.sessionDataQueries.updateSession( + sessionData.copy( + loginTimestamp = result.loginTimestamp, + ).toDbModel() + ) + } } override suspend fun getLatestSession(): SessionData? { diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/ActiveRoomsHolder.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/ActiveRoomsHolder.kt new file mode 100644 index 0000000000..c45fb0a280 --- /dev/null +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/ActiveRoomsHolder.kt @@ -0,0 +1,68 @@ +/* + * 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.services.appnavstate.api + +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +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.room.JoinedRoom +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +/** + * Holds the active rooms for a given session so they can be reused instead of instantiating new ones. + */ +@SingleIn(AppScope::class) +class ActiveRoomsHolder @Inject constructor() { + private val rooms = ConcurrentHashMap>() + + /** + * Adds a new held room for the given sessionId. + */ + fun addRoom(room: JoinedRoom) { + val roomsForSessionId = rooms.getOrPut(key = room.sessionId, defaultValue = { mutableSetOf() }) + if (roomsForSessionId.none { it.roomId == room.roomId }) { + // We don't want to add the same room multiple times + roomsForSessionId.add(room) + } + } + + /** + * Returns the last room added for the given [sessionId] or null if no room was added. + */ + fun getActiveRoom(sessionId: SessionId): JoinedRoom? { + return rooms[sessionId]?.lastOrNull() + } + + /** + * Returns an active room associated to the given [sessionId], with the given [roomId], or null if none match. + */ + fun getActiveRoomMatching(sessionId: SessionId, roomId: RoomId): JoinedRoom? { + return rooms[sessionId]?.find { it.roomId == roomId } + } + + /** + * Removes any room matching the provided [sessionId] and [roomId]. + */ + fun removeRoom(sessionId: SessionId, roomId: RoomId) { + val roomsForSessionId = rooms[sessionId] ?: return + roomsForSessionId.removeIf { it.roomId == roomId } + } + + /** + * Clears all the rooms for the given sessionId. + */ + fun clear(sessionId: SessionId) { + val activeRooms = rooms.remove(sessionId) ?: return + for (room in activeRooms) { + // Destroy the room to reset the live timelines + room.destroy() + } + } +} diff --git a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt index 6ab7856226..c6ddff060e 100644 --- a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt +++ b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt @@ -23,8 +23,8 @@ import io.element.android.services.appnavstate.test.A_SESSION_OWNER import io.element.android.services.appnavstate.test.A_SPACE_OWNER import io.element.android.services.appnavstate.test.A_THREAD_OWNER import io.element.android.services.appnavstate.test.FakeAppForegroundStateService -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test @@ -52,7 +52,7 @@ class DefaultNavigationStateServiceTest { @Test fun testNavigation() = runTest { - val service = createStateService(backgroundScope) + val service = createStateService() assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) @@ -75,14 +75,14 @@ class DefaultNavigationStateServiceTest { @Test fun testFailure() = runTest { - val service = createStateService(backgroundScope) + val service = createStateService() service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) assertThat(service.appNavigationState.value.navigationState).isEqualTo(NavigationState.Root) } @Test fun testOnNavigateToThread() = runTest { - val service = createStateService(backgroundScope) + val service = createStateService() // From root (no effect) service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) @@ -111,7 +111,7 @@ class DefaultNavigationStateServiceTest { @Test fun testOnNavigateToRoom() = runTest { - val service = createStateService(backgroundScope) + val service = createStateService() // From root (no effect) service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) @@ -140,7 +140,7 @@ class DefaultNavigationStateServiceTest { @Test fun testOnNavigateToSpace() = runTest { - val service = createStateService(backgroundScope) + val service = createStateService() // From root (no effect) service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) @@ -169,7 +169,7 @@ class DefaultNavigationStateServiceTest { @Test fun testOnNavigateToSession() = runTest { - val service = createStateService(backgroundScope) + val service = createStateService() // From root service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) @@ -198,7 +198,7 @@ class DefaultNavigationStateServiceTest { @Test fun testOnLeavingThread() = runTest { - val service = createStateService(backgroundScope) + val service = createStateService() // From root (no effect) service.onLeavingThread(A_THREAD_OWNER) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) @@ -226,7 +226,7 @@ class DefaultNavigationStateServiceTest { @Test fun testOnLeavingRoom() = runTest { - val service = createStateService(backgroundScope) + val service = createStateService() // From root (no effect) service.onLeavingRoom(A_ROOM_OWNER) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) @@ -254,7 +254,7 @@ class DefaultNavigationStateServiceTest { @Test fun testOnLeavingSpace() = runTest { - val service = createStateService(backgroundScope) + val service = createStateService() // From root (no effect) service.onLeavingSpace(A_SPACE_OWNER) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) @@ -282,7 +282,7 @@ class DefaultNavigationStateServiceTest { @Test fun testOnLeavingSession() = runTest { - val service = createStateService(backgroundScope) + val service = createStateService() // From root service.onLeavingSession(A_SESSION_OWNER) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) @@ -332,7 +332,8 @@ class DefaultNavigationStateServiceTest { onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID) } - private fun createStateService( - coroutineScope: CoroutineScope - ) = DefaultAppNavigationStateService(FakeAppForegroundStateService(), coroutineScope) + private fun TestScope.createStateService() = DefaultAppNavigationStateService( + appForegroundStateService = FakeAppForegroundStateService(), + coroutineScope = backgroundScope, + ) } diff --git a/settings.gradle.kts b/settings.gradle.kts index d63017992f..e594999bd8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,7 +39,6 @@ dependencyResolutionManagement { maven { url = URI("https://www.jitpack.io") content { - includeModule("com.github.UnifiedPush", "android-connector") includeModule("com.github.matrix-org", "matrix-analytics-events") } } diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/Error.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/Error.kt index f9e2ec0cbb..b96900cee1 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/Error.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/Error.kt @@ -7,8 +7,19 @@ package io.element.android.tests.testutils.lambda +import kotlin.system.exitProcess + fun lambdaError( message: String = "This lambda should never be called." ): Nothing { - throw AssertionError(message) + // Throwing an exception here is not enough, it can be caught. + // Instead exit the process to make sure the test fails. + // The error will be: + // "Could not stop all services." + // In this case, put a breakpoint here and run the test in debug mode to identify which lambda is failing. + System.err.println(message) + Thread.currentThread().stackTrace.forEach { + System.err.println(it) + } + exitProcess(1) } diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/ParameterMatcher.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/ParameterMatcher.kt index 8a692f67db..2b16b099bf 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/ParameterMatcher.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/lambda/ParameterMatcher.kt @@ -24,6 +24,18 @@ fun value(expectedValue: T) = object : ParameterMatcher { override fun toString(): String = "value($expectedValue)" } +/** + * A matcher that matches a value based on a condition. + * Can be used to assert that a lambda has been called with a value that satisfies a specific condition. + */ +fun matching(check: (T) -> Boolean) = object : ParameterMatcher { + override fun match(param: Any?): Boolean { + @Suppress("UNCHECKED_CAST") + return (param as? T)?.let { check(it) } ?: false + } + override fun toString(): String = "matching(condition)" +} + /** * A matcher that matches any value. * Can be used when we don't care about the value of a parameter. diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png new file mode 100644 index 0000000000..31b7ce8217 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b4adf1bb086cb46f6a9cc20dffe23cba656a571a4f6651a82063907e82a117e +size 5843 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png new file mode 100644 index 0000000000..e96cb9b338 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d7ed9363bd7b6f09be20f58019260dceb36c8cbebf1c74474b94e227bebb37d +size 5926 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderView_Day_3_en.png index cccd463c66..e7c3642e9f 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6d742af6422e7f5ce0070831e921180006a5b48d52c042fdc52030435597f242 -size 5835 +oid sha256:ef744b07b6e40d1017713b3e44c2e6d358495ea9e1ff88c183c963994e216738 +size 5554 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderView_Night_3_en.png index 6e685f122d..ab8bcda276 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b4731443df36855c8785072a8085fc9968ec231ff46ff24012b0ae72cd084e46 -size 5867 +oid sha256:04a8e2e856805b0888f901278537a217e233445b9f09060e1f4b764db87146c8 +size 5589 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png index c76898c457..28032f8d60 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5d38292c3927910a4ac9df5d2ad63dddf41818611096db2ba4a2101b880c8c0 -size 47316 +oid sha256:4406e5c57eea02b3ac315b303e0995e6ad0ae1b25f751cb8a154220e08a2bf23 +size 47284 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png new file mode 100644 index 0000000000..fcaae762cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc85189c4ebe4539c02294c5fa98d41816321848de5d68718de2dffef010719e +size 45074 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png index 81774f463f..5f4d6441ca 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a954fc1d3091b50581dcb4cdd9ca89acb0c9c92e35fc9ffd6bfe240685d37b0 -size 46333 +oid sha256:35820145f9f9fe836035774390808d5778cdca064730a3b4ebe62f66a26fafae +size 46350 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png new file mode 100644 index 0000000000..1bcba3b056 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a3e19f270562bbd81c9508e90319b819c08ecc766f5d08adc657aa2781ad679 +size 44112 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_0_en.png new file mode 100644 index 0000000000..3ca626466a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4051b09fbcb571273ff3edf21d482fa75684b73846a4992aa495274dbc8fcb97 +size 22602 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_1_en.png new file mode 100644 index 0000000000..d8831b99f6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2f1145b53dd1b031d3fb213217fd5128b5dae6f5882f0af5f339973f61d8748 +size 23556 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_2_en.png new file mode 100644 index 0000000000..605f09a379 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8dc7e4a04653998514fb3ca02bd220fdb0979c5ce8251031cf90e51f6a7265c +size 24082 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_0_en.png new file mode 100644 index 0000000000..37a1c8060a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf820f7343125063ff3e92f3d4902e427a424d148760168884cd143628925010 +size 22111 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_1_en.png new file mode 100644 index 0000000000..0b3b32b30f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9bd58639caaa55c921244a15b0f4c1d3997b4c591cab4ad46ddcfc07d7a4fdc1 +size 23010 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_2_en.png new file mode 100644 index 0000000000..7c80a5e9f4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9dfd90cd62e5511075857c5642660228b553208bda00cf07d51db087b8b37a9 +size 23508 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png index 469acb982f..f5f864b6aa 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5ac88572a7fbcacbf571b86586a887ec4931c9e92c15f2ac3633e0adc4088e4 -size 51364 +oid sha256:cb99a065e901322c590b5331880e8035b6ab6101d4e2f4603ceb3abce2344147 +size 50804 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png index 751f19d89c..485a3a2552 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:02f1214c9ced09fcb178d04ec0e8683ecb1f31c169056db9a3849e6680d060fe -size 50318 +oid sha256:ecc79b3e57881971e5bfc7d8e2937b4e9ccb18536851be3fc79154b544aa6895 +size 49800 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_CallMenuItem_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_CallMenuItem_Day_5_en.png new file mode 100644 index 0000000000..1b6fb4bab8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_CallMenuItem_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650 +size 3642 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_CallMenuItem_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_CallMenuItem_Night_5_en.png new file mode 100644 index 0000000000..d6fd8eeb70 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_CallMenuItem_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd +size 3659