diff --git a/changelog.d/8157.feature b/changelog.d/8157.feature new file mode 100644 index 0000000000..3cab2b600b --- /dev/null +++ b/changelog.d/8157.feature @@ -0,0 +1 @@ +Add aggregated unread indicator for spaces in the new layout diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 35d8d0e896..27981c3d36 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -40,6 +40,7 @@ import im.vector.app.features.discovery.DiscoverySettingsViewModel import im.vector.app.features.discovery.change.SetIdentityServerViewModel import im.vector.app.features.home.HomeActivityViewModel import im.vector.app.features.home.HomeDetailViewModel +import im.vector.app.features.home.NewHomeDetailViewModel import im.vector.app.features.home.UnknownDeviceDetectorSharedViewModel import im.vector.app.features.home.UnreadMessagesSharedViewModel import im.vector.app.features.home.UserColorAccountDataViewModel @@ -717,4 +718,9 @@ interface MavericksViewModelModule { @IntoMap @MavericksViewModelKey(RoomPollDetailViewModel::class) fun roomPollDetailViewModelFactory(factory: RoomPollDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + + @Binds + @IntoMap + @MavericksViewModelKey(NewHomeDetailViewModel::class) + fun newHomeDetailViewModelFactory(factory: NewHomeDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *> } diff --git a/vector/src/main/java/im/vector/app/features/home/GetSpacesNotificationBadgeStateUseCase.kt b/vector/src/main/java/im/vector/app/features/home/GetSpacesNotificationBadgeStateUseCase.kt new file mode 100644 index 0000000000..62d1501dab --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/GetSpacesNotificationBadgeStateUseCase.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home + +import im.vector.app.features.home.room.list.UnreadCounterBadgeView +import im.vector.app.features.spaces.GetSpacesUseCase +import im.vector.app.features.spaces.notification.GetNotificationCountForSpacesUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.query.SpaceFilter +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.spaceSummaryQueryParams +import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount +import javax.inject.Inject + +class GetSpacesNotificationBadgeStateUseCase @Inject constructor( + private val getNotificationCountForSpacesUseCase: GetNotificationCountForSpacesUseCase, + private val getSpacesUseCase: GetSpacesUseCase, +) { + + fun execute(): Flow { + val params = spaceSummaryQueryParams { + memberships = listOf(Membership.INVITE) + displayName = QueryStringValue.IsNotEmpty + } + return combine( + getNotificationCountForSpacesUseCase.execute(SpaceFilter.NoFilter), + getSpacesUseCase.execute(params), + ) { spacesNotificationCount, spaceInvites -> + computeSpacesNotificationCounterBadgeState(spacesNotificationCount, spaceInvites) + } + } + + private fun computeSpacesNotificationCounterBadgeState( + spacesNotificationCount: RoomAggregateNotificationCount, + spaceInvites: List, + ): UnreadCounterBadgeView.State { + val hasPendingSpaceInvites = spaceInvites.isNotEmpty() + return if (hasPendingSpaceInvites && spacesNotificationCount.notificationCount == 0) { + UnreadCounterBadgeView.State.Text( + text = "!", + highlighted = true, + ) + } else { + UnreadCounterBadgeView.State.Count( + count = spacesNotificationCount.notificationCount, + highlighted = spacesNotificationCount.isHighlight || hasPendingSpaceInvites, + ) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/NewHomeDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/NewHomeDetailFragment.kt index 3189c2b99e..ef855ff15b 100644 --- a/vector/src/main/java/im/vector/app/features/home/NewHomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/NewHomeDetailFragment.kt @@ -47,6 +47,7 @@ import im.vector.app.features.call.SharedKnownCallsViewModel import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.dialpad.PstnDialActivity import im.vector.app.features.call.webrtc.WebRtcCallManager +import im.vector.app.features.home.room.list.UnreadCounterBadgeView import im.vector.app.features.home.room.list.actions.RoomListSharedAction import im.vector.app.features.home.room.list.actions.RoomListSharedActionViewModel import im.vector.app.features.home.room.list.home.HomeRoomListFragment @@ -82,6 +83,7 @@ class NewHomeDetailFragment : @Inject lateinit var buildMeta: BuildMeta private val viewModel: HomeDetailViewModel by fragmentViewModel() + private val newHomeDetailViewModel: NewHomeDetailViewModel by fragmentViewModel() private val unknownDeviceDetectorSharedViewModel: UnknownDeviceDetectorSharedViewModel by activityViewModel() private val serverBackupStatusViewModel: ServerBackupStatusViewModel by activityViewModel() @@ -180,6 +182,10 @@ class NewHomeDetailFragment : currentCallsViewPresenter.updateCall(callManager.getCurrentCall(), callManager.getCalls()) invalidateOptionsMenu() } + + newHomeDetailViewModel.onEach { viewState -> + refreshUnreadCounterBadge(viewState.spacesNotificationCounterBadgeState) + } } private fun setupObservers() { @@ -379,6 +385,10 @@ class NewHomeDetailFragment : } } + private fun refreshUnreadCounterBadge(badgeState: UnreadCounterBadgeView.State) { + views.spacesUnreadCounterBadge.render(badgeState) + } + override fun onTapToReturnToCall() { callManager.getCurrentCall()?.let { call -> VectorCallActivity.newIntent( diff --git a/vector/src/main/java/im/vector/app/features/home/NewHomeDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/NewHomeDetailViewModel.kt new file mode 100644 index 0000000000..67b4645944 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/NewHomeDetailViewModel.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home + +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.EmptyAction +import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class NewHomeDetailViewModel @AssistedInject constructor( + @Assisted initialState: NewHomeDetailViewState, + private val getSpacesNotificationBadgeStateUseCase: GetSpacesNotificationBadgeStateUseCase, +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: NewHomeDetailViewState): NewHomeDetailViewModel + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + init { + observeSpacesNotificationBadgeState() + } + + private fun observeSpacesNotificationBadgeState() { + getSpacesNotificationBadgeStateUseCase.execute() + .onEach { badgeState -> setState { copy(spacesNotificationCounterBadgeState = badgeState) } } + .launchIn(viewModelScope) + } + + override fun handle(action: EmptyAction) { + // do nothing + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/NewHomeDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/NewHomeDetailViewState.kt new file mode 100644 index 0000000000..7e368fb2d1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/NewHomeDetailViewState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home + +import com.airbnb.mvrx.MavericksState +import im.vector.app.features.home.room.list.UnreadCounterBadgeView + +data class NewHomeDetailViewState( + val spacesNotificationCounterBadgeState: UnreadCounterBadgeView.State = UnreadCounterBadgeView.State.Count(count = 0, highlighted = false), +) : MavericksState diff --git a/vector/src/main/java/im/vector/app/features/spaces/GetSpacesUseCase.kt b/vector/src/main/java/im/vector/app/features/spaces/GetSpacesUseCase.kt new file mode 100644 index 0000000000..048e4b01bd --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/GetSpacesUseCase.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.spaces + +import im.vector.app.core.di.ActiveSessionHolder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams +import org.matrix.android.sdk.flow.flow +import javax.inject.Inject + +class GetSpacesUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, +) { + + fun execute(params: SpaceSummaryQueryParams): Flow> { + val session = activeSessionHolder.getSafeActiveSession() + + return session?.flow()?.liveSpaceSummaries(params) ?: emptyFlow() + } +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceListViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceListViewModel.kt index 99f6a254b8..b2776e5f87 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpaceListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceListViewModel.kt @@ -29,16 +29,13 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.plan.Interaction -import im.vector.app.features.invite.AutoAcceptInvites import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorPreferences -import kotlinx.coroutines.Dispatchers +import im.vector.app.features.spaces.notification.GetNotificationCountForSpacesUseCase import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.query.QueryStringValue @@ -48,25 +45,22 @@ import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.getUserOrDefault -import org.matrix.android.sdk.api.session.room.RoomSortOrder import org.matrix.android.sdk.api.session.room.accountdata.RoomAccountDataTypes import org.matrix.android.sdk.api.session.room.model.Membership -import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams import org.matrix.android.sdk.api.session.room.spaceSummaryQueryParams -import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount import org.matrix.android.sdk.api.session.space.SpaceOrderUtils import org.matrix.android.sdk.api.session.space.model.SpaceOrderContent import org.matrix.android.sdk.api.session.space.model.TopLevelSpaceComparator import org.matrix.android.sdk.api.util.toMatrixItem -import org.matrix.android.sdk.flow.flow class SpaceListViewModel @AssistedInject constructor( @Assisted initialState: SpaceListViewState, private val spaceStateHandler: SpaceStateHandler, private val session: Session, private val vectorPreferences: VectorPreferences, - private val autoAcceptInvites: AutoAcceptInvites, private val analyticsTracker: AnalyticsTracker, + getNotificationCountForSpacesUseCase: GetNotificationCountForSpacesUseCase, + private val getSpacesUseCase: GetSpacesUseCase, ) : VectorViewModel(initialState) { @AssistedFactory @@ -92,39 +86,14 @@ class SpaceListViewModel @AssistedInject constructor( copy(selectedSpace = selectedSpaceOption.orNull()) } - // XXX there should be a way to refactor this and share it - session.roomService().getPagedRoomSummariesLive( - roomSummaryQueryParams { - this.memberships = listOf(Membership.JOIN) - this.spaceFilter = roomsInSpaceFilter() - }, sortOrder = RoomSortOrder.NONE - ).asFlow() - .sample(300) - .onEach { - val inviteCount = if (autoAcceptInvites.hideInvites) { - 0 - } else { - session.roomService().getRoomSummaries( - roomSummaryQueryParams { this.memberships = listOf(Membership.INVITE) } - ).size - } - val totalCount = session.roomService().getNotificationCountForRooms( - roomSummaryQueryParams { - this.memberships = listOf(Membership.JOIN) - this.spaceFilter = roomsInSpaceFilter() - } - ) - val counts = RoomAggregateNotificationCount( - totalCount.notificationCount + inviteCount, - totalCount.highlightCount + inviteCount - ) + getNotificationCountForSpacesUseCase.execute(roomsInSpaceFilter()) + .onEach { counts -> setState { copy( homeAggregateCount = counts ) } } - .flowOn(Dispatchers.Default) .launchIn(viewModelScope) } @@ -267,7 +236,7 @@ class SpaceListViewModel @AssistedInject constructor( } combine( - session.flow().liveSpaceSummaries(params), + getSpacesUseCase.execute(params), session.accountDataService() .getLiveRoomAccountDataEvents(setOf(RoomAccountDataTypes.EVENT_TYPE_SPACE_ORDER)) .asFlow() diff --git a/vector/src/main/java/im/vector/app/features/spaces/notification/GetNotificationCountForSpacesUseCase.kt b/vector/src/main/java/im/vector/app/features/spaces/notification/GetNotificationCountForSpacesUseCase.kt new file mode 100644 index 0000000000..08d63e2653 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/notification/GetNotificationCountForSpacesUseCase.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.spaces.notification + +import androidx.lifecycle.asFlow +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.invite.AutoAcceptInvites +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.sample +import org.matrix.android.sdk.api.query.SpaceFilter +import org.matrix.android.sdk.api.session.room.RoomSortOrder +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams +import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount +import javax.inject.Inject + +class GetNotificationCountForSpacesUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val autoAcceptInvites: AutoAcceptInvites, +) { + + fun execute(spaceFilter: SpaceFilter): Flow { + val session = activeSessionHolder.getSafeActiveSession() + + val spaceQueryParams = roomSummaryQueryParams { + this.memberships = listOf(Membership.JOIN) + this.spaceFilter = spaceFilter + } + return session + ?.roomService() + ?.getPagedRoomSummariesLive(queryParams = spaceQueryParams, sortOrder = RoomSortOrder.NONE) + ?.asFlow() + ?.sample(300) + ?.mapLatest { + val inviteCount = if (autoAcceptInvites.hideInvites) { + 0 + } else { + session.roomService().getRoomSummaries( + roomSummaryQueryParams { this.memberships = listOf(Membership.INVITE) } + ).size + } + val totalCount = session.roomService().getNotificationCountForRooms(spaceQueryParams) + RoomAggregateNotificationCount( + notificationCount = totalCount.notificationCount + inviteCount, + highlightCount = totalCount.highlightCount + inviteCount, + ) + } + ?.flowOn(session.coroutineDispatchers.main) + ?: emptyFlow() + } +} diff --git a/vector/src/main/res/layout/fragment_new_home_detail.xml b/vector/src/main/res/layout/fragment_new_home_detail.xml index ced71a7863..8f603e57ab 100644 --- a/vector/src/main/res/layout/fragment_new_home_detail.xml +++ b/vector/src/main/res/layout/fragment_new_home_detail.xml @@ -120,6 +120,28 @@ tools:targetApi="lollipop_mr1" tools:visibility="visible" /> + + () + private val fakeGetSpacesUseCase = mockk() + + private val getSpacesNotificationBadgeStateUseCase = GetSpacesNotificationBadgeStateUseCase( + getNotificationCountForSpacesUseCase = fakeGetNotificationCountForSpacesUseCase, + getSpacesUseCase = fakeGetSpacesUseCase, + ) + + @Test + fun `given flow of spaces invite and notification count then flow of state is correct`() = runTest { + // Given + val noSpacesInvite = emptyList() + val existingSpaceInvite = listOf(mockk()) + val noNotification = RoomAggregateNotificationCount( + notificationCount = 0, + highlightCount = 0, + ) + val existingNotificationNotHighlighted = RoomAggregateNotificationCount( + notificationCount = 1, + highlightCount = 0, + ) + val existingNotificationHighlighted = RoomAggregateNotificationCount( + notificationCount = 1, + highlightCount = 1, + ) + every { fakeGetSpacesUseCase.execute(any()) } returns + flowOf(noSpacesInvite, existingSpaceInvite, existingSpaceInvite, noSpacesInvite, noSpacesInvite) + every { fakeGetNotificationCountForSpacesUseCase.execute(any()) } returns + flowOf(noNotification, noNotification, existingNotificationNotHighlighted, existingNotificationNotHighlighted, existingNotificationHighlighted) + + // When + val testObserver = getSpacesNotificationBadgeStateUseCase.execute().test(this) + advanceUntilIdle() + + // Then + val expectedState1 = UnreadCounterBadgeView.State.Count(count = 0, highlighted = false) + val expectedState2 = UnreadCounterBadgeView.State.Text(text = "!", highlighted = true) + val expectedState3 = UnreadCounterBadgeView.State.Count(count = 1, highlighted = true) + val expectedState4 = UnreadCounterBadgeView.State.Count(count = 1, highlighted = false) + val expectedState5 = UnreadCounterBadgeView.State.Count(count = 1, highlighted = true) + testObserver + .assertValues(expectedState1, expectedState2, expectedState3, expectedState4, expectedState5) + .finish() + verify { + fakeGetSpacesUseCase.execute(match { + it.memberships == listOf(Membership.INVITE) && it.displayName == QueryStringValue.IsNotEmpty + }) + } + verify { fakeGetNotificationCountForSpacesUseCase.execute(SpaceFilter.NoFilter) } + } +} diff --git a/vector/src/test/java/im/vector/app/features/home/NewHomeDetailViewModelTest.kt b/vector/src/test/java/im/vector/app/features/home/NewHomeDetailViewModelTest.kt new file mode 100644 index 0000000000..39adc0a811 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/home/NewHomeDetailViewModelTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home + +import com.airbnb.mvrx.test.MavericksTestRule +import im.vector.app.features.home.room.list.UnreadCounterBadgeView +import im.vector.app.test.test +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Rule +import org.junit.Test + +internal class NewHomeDetailViewModelTest { + + @get:Rule + val mavericksTestRule = MavericksTestRule(testDispatcher = UnconfinedTestDispatcher()) + + private val initialState = NewHomeDetailViewState() + private val fakeGetSpacesNotificationBadgeStateUseCase = mockk() + + private fun createViewModel(): NewHomeDetailViewModel { + return NewHomeDetailViewModel( + initialState = initialState, + getSpacesNotificationBadgeStateUseCase = fakeGetSpacesNotificationBadgeStateUseCase, + ) + } + + @Test + fun `given the viewModel is created then viewState is updated with space notifications badge state`() { + // Given + val aBadgeState = UnreadCounterBadgeView.State.Count(count = 1, highlighted = false) + every { fakeGetSpacesNotificationBadgeStateUseCase.execute() } returns flowOf(aBadgeState) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + + // Then + val expectedViewState = initialState.copy( + spacesNotificationCounterBadgeState = aBadgeState, + ) + viewModelTest + .assertLatestState(expectedViewState) + .finish() + verify { + fakeGetSpacesNotificationBadgeStateUseCase.execute() + } + } +} diff --git a/vector/src/test/java/im/vector/app/features/spaces/GetSpacesUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/spaces/GetSpacesUseCaseTest.kt new file mode 100644 index 0000000000..2e8d50ff3f --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/spaces/GetSpacesUseCaseTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.spaces + +import im.vector.app.test.fakes.FakeActiveSessionHolder +import im.vector.app.test.fakes.FakeFlowLiveDataConversions +import im.vector.app.test.fakes.givenAsFlow +import im.vector.app.test.test +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams + +internal class GetSpacesUseCaseTest { + + private val fakeActiveSessionHolder = FakeActiveSessionHolder() + private val fakeFlowLiveDataConversions = FakeFlowLiveDataConversions() + + private val getSpacesUseCase = GetSpacesUseCase( + activeSessionHolder = fakeActiveSessionHolder.instance, + ) + + @Before + fun setUp() { + fakeFlowLiveDataConversions.setup() + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given params when execute then the list of summaries is returned`() = runTest { + // Given + val queryParams = givenSpaceQueryParams() + val firstSummaries = listOf(mockk()) + val nextSummaries = listOf(mockk()) + fakeActiveSessionHolder.fakeSession + .fakeSpaceService + .givenGetSpaceSummariesReturns(firstSummaries) + fakeActiveSessionHolder.fakeSession + .fakeSpaceService + .givenGetSpaceSummariesLiveReturns(nextSummaries) + .givenAsFlow() + + // When + val testObserver = getSpacesUseCase.execute(queryParams).test(this) + advanceUntilIdle() + + // Then + testObserver + .assertValues(firstSummaries, nextSummaries) + .finish() + verify { + fakeActiveSessionHolder.fakeSession.fakeSpaceService.getSpaceSummaries(queryParams) + fakeActiveSessionHolder.fakeSession.fakeSpaceService.getSpaceSummariesLive(queryParams) + } + } + + @Test + fun `given no active session when execute then empty flow is returned`() = runTest { + // Given + fakeActiveSessionHolder.givenGetSafeActiveSessionReturns(null) + val queryParams = givenSpaceQueryParams() + + // When + val testObserver = getSpacesUseCase.execute(queryParams).test(this) + advanceUntilIdle() + + // Then + testObserver + .assertNoValues() + .finish() + verify(inverse = true) { + fakeActiveSessionHolder.fakeSession.fakeSpaceService.getSpaceSummaries(queryParams) + fakeActiveSessionHolder.fakeSession.fakeSpaceService.getSpaceSummariesLive(queryParams) + } + } + + private fun givenSpaceQueryParams(): SpaceSummaryQueryParams { + return mockk() + } +} diff --git a/vector/src/test/java/im/vector/app/features/spaces/notification/GetNotificationCountForSpacesUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/spaces/notification/GetNotificationCountForSpacesUseCaseTest.kt new file mode 100644 index 0000000000..8d4bdb1b30 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/spaces/notification/GetNotificationCountForSpacesUseCaseTest.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.spaces.notification + +import androidx.paging.PagedList +import im.vector.app.test.fakes.FakeActiveSessionHolder +import im.vector.app.test.fakes.FakeAutoAcceptInvites +import im.vector.app.test.fakes.FakeFlowLiveDataConversions +import im.vector.app.test.fakes.givenAsFlow +import im.vector.app.test.test +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.query.SpaceFilter +import org.matrix.android.sdk.api.session.room.RoomSortOrder +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount + +internal class GetNotificationCountForSpacesUseCaseTest { + + private val fakeActiveSessionHolder = FakeActiveSessionHolder() + private val fakeAutoAcceptInvites = FakeAutoAcceptInvites() + private val fakeFlowLiveDataConversions = FakeFlowLiveDataConversions() + + private val getNotificationCountForSpacesUseCase = GetNotificationCountForSpacesUseCase( + activeSessionHolder = fakeActiveSessionHolder.instance, + autoAcceptInvites = fakeAutoAcceptInvites, + ) + + @Before + fun setUp() { + fakeFlowLiveDataConversions.setup() + mockkStatic("kotlinx.coroutines.flow.FlowKt") + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given space filter and auto accept invites when execute then correct notification count is returned`() = runTest { + // given + val spaceFilter = SpaceFilter.NoFilter + val pagedList = mockk>() + val pagedListFlow = fakeActiveSessionHolder.fakeSession + .fakeRoomService + .givenGetPagedRoomSummariesLiveReturns(pagedList) + .givenAsFlow() + every { pagedListFlow.sample(any()) } returns pagedListFlow + val expectedNotificationCount = RoomAggregateNotificationCount( + notificationCount = 1, + highlightCount = 0, + ) + fakeActiveSessionHolder.fakeSession + .fakeRoomService + .givenGetNotificationCountForRoomsReturns(expectedNotificationCount) + fakeAutoAcceptInvites._isEnabled = true + + // When + val testObserver = getNotificationCountForSpacesUseCase.execute(spaceFilter).test(this) + advanceUntilIdle() + + // Then + testObserver + .assertValues(expectedNotificationCount) + .finish() + verify { + fakeActiveSessionHolder.fakeSession.fakeRoomService.getNotificationCountForRooms( + queryParams = match { it.memberships == listOf(Membership.JOIN) && it.spaceFilter == spaceFilter } + ) + fakeActiveSessionHolder.fakeSession.fakeRoomService.getPagedRoomSummariesLive( + queryParams = match { it.memberships == listOf(Membership.JOIN) && it.spaceFilter == spaceFilter }, + pagedListConfig = any(), + sortOrder = RoomSortOrder.NONE, + ) + } + } + + @Test + fun `given space filter and show invites when execute then correct notification count is returned`() = runTest { + // given + val spaceFilter = SpaceFilter.NoFilter + val pagedList = mockk>() + val pagedListFlow = fakeActiveSessionHolder.fakeSession + .fakeRoomService + .givenGetPagedRoomSummariesLiveReturns(pagedList) + .givenAsFlow() + every { pagedListFlow.sample(any()) } returns pagedListFlow + val notificationCount = RoomAggregateNotificationCount( + notificationCount = 1, + highlightCount = 0, + ) + fakeActiveSessionHolder.fakeSession + .fakeRoomService + .givenGetNotificationCountForRoomsReturns(notificationCount) + val invitedRooms = listOf(mockk()) + fakeActiveSessionHolder.fakeSession + .fakeRoomService + .givenGetRoomSummaries(invitedRooms) + fakeAutoAcceptInvites._isEnabled = false + val expectedNotificationCount = RoomAggregateNotificationCount( + notificationCount = notificationCount.notificationCount + invitedRooms.size, + highlightCount = notificationCount.highlightCount + invitedRooms.size, + ) + + // When + val testObserver = getNotificationCountForSpacesUseCase.execute(spaceFilter).test(this) + advanceUntilIdle() + + // Then + testObserver + .assertValues(expectedNotificationCount) + .finish() + verify { + fakeActiveSessionHolder.fakeSession.fakeRoomService.getRoomSummaries( + queryParams = match { it.memberships == listOf(Membership.INVITE) } + ) + fakeActiveSessionHolder.fakeSession.fakeRoomService.getNotificationCountForRooms( + queryParams = match { it.memberships == listOf(Membership.JOIN) && it.spaceFilter == spaceFilter } + ) + fakeActiveSessionHolder.fakeSession.fakeRoomService.getPagedRoomSummariesLive( + queryParams = match { it.memberships == listOf(Membership.JOIN) && it.spaceFilter == spaceFilter }, + pagedListConfig = any(), + sortOrder = RoomSortOrder.NONE, + ) + } + } + + @Test + fun `given no active session when execute then empty flow is returned`() = runTest { + // given + val spaceFilter = SpaceFilter.NoFilter + fakeActiveSessionHolder.givenGetSafeActiveSessionReturns(null) + + // When + val testObserver = getNotificationCountForSpacesUseCase.execute(spaceFilter).test(this) + + // Then + testObserver + .assertNoValues() + .finish() + verify(inverse = true) { + fakeActiveSessionHolder.fakeSession.fakeRoomService.getPagedRoomSummariesLive( + queryParams = any(), + pagedListConfig = any(), + sortOrder = any(), + ) + } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeFlowLiveDataConversions.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeFlowLiveDataConversions.kt index 956a86f32e..cdbe828521 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeFlowLiveDataConversions.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeFlowLiveDataConversions.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.asFlow import io.mockk.every import io.mockk.mockkStatic +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf class FakeFlowLiveDataConversions { @@ -28,6 +29,8 @@ class FakeFlowLiveDataConversions { } } -fun LiveData.givenAsFlow() { - every { asFlow() } returns flowOf(value!!) +fun LiveData.givenAsFlow(): Flow { + return flowOf(value!!).also { + every { asFlow() } returns it + } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeRoomService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeRoomService.kt index e957266383..63209222b2 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeRoomService.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeRoomService.kt @@ -16,10 +16,14 @@ package im.vector.app.test.fakes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.paging.PagedList import io.mockk.every import io.mockk.mockk import org.matrix.android.sdk.api.session.room.RoomService import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount class FakeRoomService( private val fakeRoom: FakeRoom = FakeRoom() @@ -34,4 +38,18 @@ class FakeRoomService( fun set(roomSummary: RoomSummary?) { every { getRoomSummary(any()) } returns roomSummary } + + fun givenGetPagedRoomSummariesLiveReturns(pagedList: PagedList): LiveData> { + return MutableLiveData(pagedList).also { + every { getPagedRoomSummariesLive(queryParams = any(), pagedListConfig = any(), sortOrder = any()) } returns it + } + } + + fun givenGetNotificationCountForRoomsReturns(roomAggregateNotificationCount: RoomAggregateNotificationCount) { + every { getNotificationCountForRooms(queryParams = any()) } returns roomAggregateNotificationCount + } + + fun givenGetRoomSummaries(roomSummaries: List) { + every { getRoomSummaries(any()) } returns roomSummaries + } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt index 1b6d3e2729..ada23c159e 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt @@ -46,6 +46,7 @@ class FakeSession( val fakeUserService: FakeUserService = FakeUserService(), private val fakeEventService: FakeEventService = FakeEventService(), val fakeSessionAccountDataService: FakeSessionAccountDataService = FakeSessionAccountDataService(), + val fakeSpaceService: FakeSpaceService = FakeSpaceService(), ) : Session by mockk(relaxed = true) { init { @@ -66,6 +67,7 @@ class FakeSession( override fun pushersService() = fakePushersService override fun accountDataService() = fakeSessionAccountDataService override fun userService() = fakeUserService + override fun spaceService() = fakeSpaceService fun givenVectorStore(vectorSessionStore: VectorSessionStore) { coEvery { diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSpaceService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSpaceService.kt new file mode 100644 index 0000000000..59c7d5524d --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSpaceService.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.mockk.every +import io.mockk.mockk +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.space.SpaceService + +class FakeSpaceService : SpaceService by mockk() { + + fun givenGetSpaceSummariesLiveReturns(roomSummaries: List): LiveData> { + return MutableLiveData(roomSummaries).also { + every { getSpaceSummariesLive(any()) } returns it + } + } + + fun givenGetSpaceSummariesReturns(roomSummaries: List) { + every { getSpaceSummaries(any()) } returns roomSummaries + } +}