diff --git a/changelog.d/7396.feature b/changelog.d/7396.feature new file mode 100644 index 0000000000..8ce14eb3d3 --- /dev/null +++ b/changelog.d/7396.feature @@ -0,0 +1 @@ +Multi selection in sessions list diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index d5223a0638..897c2853d8 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -1,6 +1,13 @@ + + + %1$d selected + %1$d selected + + + %s\'s invitation Your invitation %1$s created the room @@ -407,6 +414,8 @@ Learn more Next Got it + Select all + Deselect all Copied to clipboard @@ -3328,6 +3337,7 @@ No unverified sessions found. No inactive sessions found. Clear Filter + Select sessions Sign out of this session Session details Application, device, and activity information. diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt index a47ea7e917..4864c41394 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt @@ -30,4 +30,5 @@ data class DeviceFullInfo( val isCurrentDevice: Boolean, val deviceExtendedInfo: DeviceExtendedInfo, val matrixClientInfo: MatrixClientInfoContent?, + val isSelected: Boolean = false, ) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt index c507699e0b..1c348af4f9 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt @@ -331,6 +331,10 @@ class VectorSettingsDevicesFragment : views.waitingView.root.isVisible = isLoading } + override fun onOtherSessionLongClicked(deviceId: String) { + // do nothing + } + override fun onOtherSessionClicked(deviceId: String) { navigateToSessionOverview(deviceId) } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt index f83f069a9f..de1cd33d35 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt @@ -17,6 +17,8 @@ package im.vector.app.features.settings.devices.v2.list import android.graphics.drawable.Drawable +import android.view.View +import android.view.View.OnLongClickListener import android.widget.ImageView import android.widget.TextView import androidx.annotation.ColorInt @@ -27,6 +29,8 @@ import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.epoxy.onClick +import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.StringProvider import im.vector.app.core.ui.views.ShieldImageView import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel @@ -56,19 +60,39 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la @EpoxyAttribute lateinit var stringProvider: StringProvider + @EpoxyAttribute + lateinit var colorProvider: ColorProvider + + @EpoxyAttribute + lateinit var drawableProvider: DrawableProvider + + @EpoxyAttribute + var selected: Boolean = false + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var clickListener: ClickListener? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + var onLongClickListener: OnLongClickListener? = null + private val setDeviceTypeIconUseCase = SetDeviceTypeIconUseCase() override fun bind(holder: Holder) { super.bind(holder) holder.view.onClick(clickListener) - if (clickListener == null) { + holder.view.setOnLongClickListener(onLongClickListener) + if (clickListener == null && onLongClickListener == null) { holder.view.isClickable = false } - setDeviceTypeIconUseCase.execute(deviceType, holder.otherSessionDeviceTypeImageView, stringProvider) + holder.otherSessionDeviceTypeImageView.isSelected = selected + if (selected) { + val drawableColor = colorProvider.getColorFromAttribute(android.R.attr.colorBackground) + val drawable = drawableProvider.getDrawable(R.drawable.ic_check_on, drawableColor) + holder.otherSessionDeviceTypeImageView.setImageDrawable(drawable) + } else { + setDeviceTypeIconUseCase.execute(deviceType, holder.otherSessionDeviceTypeImageView, stringProvider) + } holder.otherSessionVerificationStatusImageView.render(roomEncryptionTrustLevel) holder.otherSessionNameTextView.text = sessionName holder.otherSessionDescriptionTextView.text = sessionDescription @@ -76,6 +100,7 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la holder.otherSessionDescriptionTextView.setTextColor(it) } holder.otherSessionDescriptionTextView.setCompoundDrawablesWithIntrinsicBounds(sessionDescriptionDrawable, null, null, null) + holder.otherSessionItemBackgroundView.isSelected = selected } class Holder : VectorEpoxyHolder() { @@ -83,5 +108,6 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la val otherSessionVerificationStatusImageView by bind(R.id.otherSessionVerificationStatusImageView) val otherSessionNameTextView by bind(R.id.otherSessionNameTextView) val otherSessionDescriptionTextView by bind(R.id.otherSessionDescriptionTextView) + val otherSessionItemBackgroundView by bind(R.id.otherSessionItemBackground) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt index 59e7e1888e..8d70552101 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2.list +import android.view.View import com.airbnb.epoxy.TypedEpoxyController import im.vector.app.R import im.vector.app.core.date.DateFormatKind @@ -38,6 +39,7 @@ class OtherSessionsController @Inject constructor( var callback: Callback? = null interface Callback { + fun onItemLongClicked(deviceId: String) fun onItemClicked(deviceId: String) } @@ -70,8 +72,15 @@ class OtherSessionsController @Inject constructor( sessionDescription(description) sessionDescriptionDrawable(descriptionDrawable) sessionDescriptionColor(descriptionColor) - stringProvider(this@OtherSessionsController.stringProvider) + stringProvider(host.stringProvider) + colorProvider(host.colorProvider) + drawableProvider(host.drawableProvider) + selected(device.isSelected) clickListener { device.deviceInfo.deviceId?.let { host.callback?.onItemClicked(it) } } + onLongClickListener(View.OnLongClickListener { + device.deviceInfo.deviceId?.let { host.callback?.onItemLongClicked(it) } + true + }) } } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsView.kt index 6f6956c885..a4f8bb64db 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsView.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsView.kt @@ -40,6 +40,7 @@ class OtherSessionsView @JvmOverloads constructor( ) : ConstraintLayout(context, attrs, defStyleAttr), OtherSessionsController.Callback { interface Callback { + fun onOtherSessionLongClicked(deviceId: String) fun onOtherSessionClicked(deviceId: String) fun onViewAllOtherSessionsClicked() } @@ -107,4 +108,8 @@ class OtherSessionsView @JvmOverloads constructor( override fun onItemClicked(deviceId: String) { callback?.onOtherSessionClicked(deviceId) } + + override fun onItemLongClicked(deviceId: String) { + callback?.onOtherSessionLongClicked(deviceId) + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt index 7164ecc866..1978708ebf 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt @@ -21,4 +21,9 @@ import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType sealed class OtherSessionsAction : VectorViewModelAction { data class FilterDevices(val filterType: DeviceManagerFilterType) : OtherSessionsAction() + data class EnableSelectMode(val deviceId: String?) : OtherSessionsAction() + object DisableSelectMode : OtherSessionsAction() + data class ToggleSelectionForDevice(val deviceId: String) : OtherSessionsAction() + object SelectAll : OtherSessionsAction() + object DeselectAll : OtherSessionsAction() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt index 610776e22e..4f1c8353f5 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt @@ -18,8 +18,12 @@ package im.vector.app.features.settings.devices.v2.othersessions import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem import android.view.View import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback import androidx.annotation.StringRes import androidx.core.view.isVisible import com.airbnb.mvrx.Success @@ -31,7 +35,9 @@ import im.vector.app.R import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment.ResultListener.Companion.RESULT_OK import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.resources.StringProvider import im.vector.app.databinding.FragmentOtherSessionsBinding import im.vector.app.features.settings.devices.v2.DeviceFullInfo import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterBottomSheet @@ -40,25 +46,79 @@ import im.vector.app.features.settings.devices.v2.list.OtherSessionsView import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet import im.vector.app.features.themes.ThemeUtils +import org.matrix.android.sdk.api.extensions.orFalse import javax.inject.Inject @AndroidEntryPoint class OtherSessionsFragment : VectorBaseFragment(), VectorBaseBottomSheetDialogFragment.ResultListener, - OtherSessionsView.Callback { + OtherSessionsView.Callback, + VectorMenuProvider { private val viewModel: OtherSessionsViewModel by fragmentViewModel() private val args: OtherSessionsArgs by args() @Inject lateinit var colorProvider: ColorProvider + @Inject lateinit var stringProvider: StringProvider + @Inject lateinit var viewNavigator: OtherSessionsViewNavigator override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentOtherSessionsBinding { return FragmentOtherSessionsBinding.inflate(layoutInflater, container, false) } + override fun getMenuRes() = R.menu.menu_other_sessions + + override fun handlePrepareMenu(menu: Menu) { + withState(viewModel) { state -> + val isSelectModeEnabled = state.isSelectModeEnabled + menu.findItem(R.id.otherSessionsSelectAll).isVisible = isSelectModeEnabled + menu.findItem(R.id.otherSessionsDeselectAll).isVisible = isSelectModeEnabled + menu.findItem(R.id.otherSessionsSelect).isVisible = !isSelectModeEnabled && state.devices()?.isNotEmpty().orFalse() + } + } + + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.otherSessionsSelect -> { + enableSelectMode(true) + true + } + R.id.otherSessionsSelectAll -> { + viewModel.handle(OtherSessionsAction.SelectAll) + true + } + R.id.otherSessionsDeselectAll -> { + viewModel.handle(OtherSessionsAction.DeselectAll) + true + } + else -> false + } + } + + private fun enableSelectMode(isEnabled: Boolean, deviceId: String? = null) { + val action = if (isEnabled) OtherSessionsAction.EnableSelectMode(deviceId) else OtherSessionsAction.DisableSelectMode + viewModel.handle(action) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + activity?.onBackPressedDispatcher?.addCallback(owner = this) { + handleBackPress(this) + } + } + + private fun handleBackPress(onBackPressedCallback: OnBackPressedCallback) = withState(viewModel) { state -> + if (state.isSelectModeEnabled) { + enableSelectMode(false) + } else { + onBackPressedCallback.isEnabled = false + activity?.onBackPressedDispatcher?.onBackPressed() + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupToolbar(views.otherSessionsToolbar).setTitle(args.titleResourceId).allowBack() @@ -103,11 +163,24 @@ class OtherSessionsFragment : override fun invalidate() = withState(viewModel) { state -> if (state.devices is Success) { - renderDevices(state.devices(), state.currentFilter) + val devices = state.devices.invoke() + renderDevices(devices, state.currentFilter) + updateToolbar(devices, state.isSelectModeEnabled) } } - private fun renderDevices(devices: List?, currentFilter: DeviceManagerFilterType) { + private fun updateToolbar(devices: List, isSelectModeEnabled: Boolean) { + invalidateOptionsMenu() + val title = if (isSelectModeEnabled) { + val selection = devices.count { it.isSelected } + stringProvider.getQuantityString(R.plurals.x_selected, selection, selection) + } else { + getString(args.titleResourceId) + } + toolbar?.title = title + } + + private fun renderDevices(devices: List, currentFilter: DeviceManagerFilterType) { views.otherSessionsFilterBadgeImageView.isVisible = currentFilter != DeviceManagerFilterType.ALL_SESSIONS views.otherSessionsSecurityRecommendationView.isVisible = currentFilter != DeviceManagerFilterType.ALL_SESSIONS views.deviceListHeaderOtherSessions.isVisible = currentFilter == DeviceManagerFilterType.ALL_SESSIONS @@ -160,7 +233,7 @@ class OtherSessionsFragment : } } - if (devices.isNullOrEmpty()) { + if (devices.isEmpty()) { views.deviceListOtherSessions.isVisible = false views.otherSessionsNotFoundLayout.isVisible = true } else { @@ -190,11 +263,21 @@ class OtherSessionsFragment : SessionLearnMoreBottomSheet.show(childFragmentManager, args) } - override fun onOtherSessionClicked(deviceId: String) { - viewNavigator.navigateToSessionOverview( - context = requireActivity(), - deviceId = deviceId - ) + override fun onOtherSessionLongClicked(deviceId: String) = withState(viewModel) { state -> + if (!state.isSelectModeEnabled) { + enableSelectMode(true, deviceId) + } + } + + override fun onOtherSessionClicked(deviceId: String) = withState(viewModel) { state -> + if (state.isSelectModeEnabled) { + viewModel.handle(OtherSessionsAction.ToggleSelectionForDevice(deviceId)) + } else { + viewNavigator.navigateToSessionOverview( + context = requireActivity(), + deviceId = deviceId + ) + } } override fun onViewAllOtherSessionsClicked() { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt index e52953e2b6..2cd0c6af66 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt @@ -17,6 +17,7 @@ package im.vector.app.features.settings.devices.v2.othersessions import com.airbnb.mvrx.MavericksViewModelFactory +import com.airbnb.mvrx.Success import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -67,6 +68,11 @@ class OtherSessionsViewModel @AssistedInject constructor( override fun handle(action: OtherSessionsAction) { when (action) { is OtherSessionsAction.FilterDevices -> handleFilterDevices(action) + OtherSessionsAction.DisableSelectMode -> handleDisableSelectMode() + is OtherSessionsAction.EnableSelectMode -> handleEnableSelectMode(action.deviceId) + is OtherSessionsAction.ToggleSelectionForDevice -> handleToggleSelectionForDevice(action.deviceId) + OtherSessionsAction.DeselectAll -> handleDeselectAll() + OtherSessionsAction.SelectAll -> handleSelectAll() } } @@ -78,4 +84,62 @@ class OtherSessionsViewModel @AssistedInject constructor( } observeDevices(action.filterType) } + + private fun handleDisableSelectMode() { + setSelectionForAllDevices(isSelected = false, enableSelectMode = false) + } + + private fun handleEnableSelectMode(deviceId: String?) { + toggleSelectionForDevice(deviceId, enableSelectMode = true) + } + + private fun handleToggleSelectionForDevice(deviceId: String) = withState { state -> + toggleSelectionForDevice(deviceId, enableSelectMode = state.isSelectModeEnabled) + } + + private fun toggleSelectionForDevice(deviceId: String?, enableSelectMode: Boolean) = withState { state -> + val updatedDevices = if (state.devices is Success) { + val devices = state.devices.invoke().toMutableList() + val indexToUpdate = devices.indexOfFirst { it.deviceInfo.deviceId == deviceId } + if (indexToUpdate >= 0) { + val currentInfo = devices[indexToUpdate] + val updatedInfo = currentInfo.copy(isSelected = !currentInfo.isSelected) + devices[indexToUpdate] = updatedInfo + } + Success(devices) + } else { + state.devices + } + + setState { + copy( + devices = updatedDevices, + isSelectModeEnabled = enableSelectMode + ) + } + } + + private fun handleSelectAll() = withState { state -> + setSelectionForAllDevices(isSelected = true, enableSelectMode = state.isSelectModeEnabled) + } + + private fun handleDeselectAll() = withState { state -> + setSelectionForAllDevices(isSelected = false, enableSelectMode = state.isSelectModeEnabled) + } + + private fun setSelectionForAllDevices(isSelected: Boolean, enableSelectMode: Boolean) = withState { state -> + val updatedDevices = if (state.devices is Success) { + val updatedDevices = state.devices.invoke().map { it.copy(isSelected = isSelected) } + Success(updatedDevices) + } else { + state.devices + } + + setState { + copy( + devices = updatedDevices, + isSelectModeEnabled = enableSelectMode + ) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt index 5256a9b27a..0db3c8cd0e 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt @@ -26,6 +26,7 @@ data class OtherSessionsViewState( val devices: Async> = Uninitialized, val currentFilter: DeviceManagerFilterType = DeviceManagerFilterType.ALL_SESSIONS, val excludeCurrentDevice: Boolean = false, + val isSelectModeEnabled: Boolean = false, ) : MavericksState { constructor(args: OtherSessionsArgs) : this(excludeCurrentDevice = args.excludeCurrentDevice) diff --git a/vector/src/main/res/drawable/bg_device_type.xml b/vector/src/main/res/drawable/bg_device_type.xml index 88a90ccbe6..dc85b723a8 100644 --- a/vector/src/main/res/drawable/bg_device_type.xml +++ b/vector/src/main/res/drawable/bg_device_type.xml @@ -1,7 +1,13 @@ - - - - - + + + + + + + + + + + + diff --git a/vector/src/main/res/drawable/bg_other_session.xml b/vector/src/main/res/drawable/bg_other_session.xml new file mode 100644 index 0000000000..a0c988b8a2 --- /dev/null +++ b/vector/src/main/res/drawable/bg_other_session.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/vector/src/main/res/layout/item_other_session.xml b/vector/src/main/res/layout/item_other_session.xml index 2f93c2be5d..f514cea56b 100644 --- a/vector/src/main/res/layout/item_other_session.xml +++ b/vector/src/main/res/layout/item_other_session.xml @@ -5,30 +5,45 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:foreground="?selectableItemBackground" - android:paddingTop="16dp"> + android:paddingHorizontal="8dp" + android:paddingTop="8dp"> + + @@ -59,10 +76,10 @@ + app:layout_constraintTop_toBottomOf="@id/otherSessionItemBackground" /> diff --git a/vector/src/main/res/layout/view_other_sessions.xml b/vector/src/main/res/layout/view_other_sessions.xml index aacbbe8ffe..2d02870174 100644 --- a/vector/src/main/res/layout/view_other_sessions.xml +++ b/vector/src/main/res/layout/view_other_sessions.xml @@ -9,7 +9,6 @@ android:id="@+id/otherSessionsRecyclerView" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="16dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" @@ -21,6 +20,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:padding="0dp" + android:layout_marginStart="16dp" app:layout_constraintStart_toStartOf="@id/otherSessionsRecyclerView" app:layout_constraintTop_toBottomOf="@id/otherSessionsRecyclerView" tools:text="@string/device_manager_other_sessions_view_all" /> diff --git a/vector/src/main/res/menu/menu_other_sessions.xml b/vector/src/main/res/menu/menu_other_sessions.xml new file mode 100644 index 0000000000..8339286fe7 --- /dev/null +++ b/vector/src/main/res/menu/menu_other_sessions.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt new file mode 100644 index 0000000000..e7b8eeee9b --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2022 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.settings.devices.v2.othersessions + +import android.os.SystemClock +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.test.MavericksTestRule +import im.vector.app.features.settings.devices.v2.DeviceFullInfo +import im.vector.app.features.settings.devices.v2.GetDeviceFullInfoListUseCase +import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase +import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType +import im.vector.app.test.fakes.FakeActiveSessionHolder +import im.vector.app.test.fakes.FakeVerificationService +import im.vector.app.test.fixtures.aDeviceFullInfo +import im.vector.app.test.test +import im.vector.app.test.testDispatcher +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verifyAll +import kotlinx.coroutines.flow.flowOf +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +private const val A_TITLE_RES_ID = 1 +private const val A_DEVICE_ID = "device-id" + +class OtherSessionsViewModelTest { + + @get:Rule + val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher) + + private val defaultArgs = OtherSessionsArgs( + titleResourceId = A_TITLE_RES_ID, + defaultFilter = DeviceManagerFilterType.ALL_SESSIONS, + excludeCurrentDevice = false, + ) + + private val fakeActiveSessionHolder = FakeActiveSessionHolder() + private val fakeGetDeviceFullInfoListUseCase = mockk() + private val fakeRefreshDevicesUseCaseUseCase = mockk() + + private fun createViewModel(args: OtherSessionsArgs = defaultArgs) = OtherSessionsViewModel( + initialState = OtherSessionsViewState(args), + activeSessionHolder = fakeActiveSessionHolder.instance, + getDeviceFullInfoListUseCase = fakeGetDeviceFullInfoListUseCase, + refreshDevicesUseCase = fakeRefreshDevicesUseCaseUseCase, + ) + + @Before + fun setup() { + // Needed for internal usage of Flow.throttleFirst() inside the ViewModel + mockkStatic(SystemClock::class) + every { SystemClock.elapsedRealtime() } returns 1234 + + givenVerificationService() + } + + private fun givenVerificationService(): FakeVerificationService { + val fakeVerificationService = fakeActiveSessionHolder + .fakeSession + .fakeCryptoService + .fakeVerificationService + fakeVerificationService.givenAddListenerSucceeds() + fakeVerificationService.givenRemoveListenerSucceeds() + return fakeVerificationService + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given the viewModel has been initialized then viewState is updated with devices list`() { + // Given + val devices = mockk>() + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + val expectedState = OtherSessionsViewState( + devices = Success(devices), + currentFilter = defaultArgs.defaultFilter, + excludeCurrentDevice = defaultArgs.excludeCurrentDevice, + isSelectModeEnabled = false, + ) + + // When + val viewModel = createViewModel() + + // Then + viewModel.test() + .assertLatestState { state -> state == expectedState } + .finish() + verifyAll { fakeGetDeviceFullInfoListUseCase.execute(defaultArgs.defaultFilter, defaultArgs.excludeCurrentDevice) } + } + + @Test + fun `given filter devices action when handling the action then viewState is updated with filter option and devices are filtered`() { + // Given + val filterType = DeviceManagerFilterType.UNVERIFIED + val devices = mockk>() + val filteredDevices = mockk>() + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + givenGetDeviceFullInfoListReturns(filterType = filterType, filteredDevices) + val expectedState = OtherSessionsViewState( + devices = Success(filteredDevices), + currentFilter = filterType, + excludeCurrentDevice = defaultArgs.excludeCurrentDevice, + isSelectModeEnabled = false, + ) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.FilterDevices(filterType)) + + // Then + viewModelTest + .assertLatestState { state -> state == expectedState } + .finish() + verifyAll { + fakeGetDeviceFullInfoListUseCase.execute(defaultArgs.defaultFilter, defaultArgs.excludeCurrentDevice) + fakeGetDeviceFullInfoListUseCase.execute(filterType, defaultArgs.excludeCurrentDevice) + } + } + + @Test + fun `given enable select mode action when handling the action then viewState is updated with correct info`() { + // Given + val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID, isSelected = false) + val devices: List = listOf(deviceFullInfo) + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + val expectedState = OtherSessionsViewState( + devices = Success(listOf(deviceFullInfo.copy(isSelected = true))), + currentFilter = defaultArgs.defaultFilter, + excludeCurrentDevice = defaultArgs.excludeCurrentDevice, + isSelectModeEnabled = true, + ) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.EnableSelectMode(A_DEVICE_ID)) + + // Then + viewModelTest + .assertLatestState { state -> state == expectedState } + .finish() + } + + @Test + fun `given disable select mode action when handling the action then viewState is updated with correct info`() { + // Given + val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true) + val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true) + val devices: List = listOf(deviceFullInfo1, deviceFullInfo2) + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + val expectedState = OtherSessionsViewState( + devices = Success(listOf(deviceFullInfo1.copy(isSelected = false), deviceFullInfo2.copy(isSelected = false))), + currentFilter = defaultArgs.defaultFilter, + excludeCurrentDevice = defaultArgs.excludeCurrentDevice, + isSelectModeEnabled = false, + ) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.DisableSelectMode) + + // Then + viewModelTest + .assertLatestState { state -> state == expectedState } + .finish() + } + + @Test + fun `given toggle selection for device action when handling the action then viewState is updated with correct info`() { + // Given + val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID, isSelected = false) + val devices: List = listOf(deviceFullInfo) + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + val expectedState = OtherSessionsViewState( + devices = Success(listOf(deviceFullInfo.copy(isSelected = true))), + currentFilter = defaultArgs.defaultFilter, + excludeCurrentDevice = defaultArgs.excludeCurrentDevice, + isSelectModeEnabled = false, + ) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.ToggleSelectionForDevice(A_DEVICE_ID)) + + // Then + viewModelTest + .assertLatestState { state -> state == expectedState } + .finish() + } + + @Test + fun `given select all action when handling the action then viewState is updated with correct info`() { + // Given + val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID, isSelected = false) + val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true) + val devices: List = listOf(deviceFullInfo1, deviceFullInfo2) + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + val expectedState = OtherSessionsViewState( + devices = Success(listOf(deviceFullInfo1.copy(isSelected = true), deviceFullInfo2.copy(isSelected = true))), + currentFilter = defaultArgs.defaultFilter, + excludeCurrentDevice = defaultArgs.excludeCurrentDevice, + isSelectModeEnabled = false, + ) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.SelectAll) + + // Then + viewModelTest + .assertLatestState { state -> state == expectedState } + .finish() + } + + @Test + fun `given deselect all action when handling the action then viewState is updated with correct info`() { + // Given + val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID, isSelected = false) + val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true) + val devices: List = listOf(deviceFullInfo1, deviceFullInfo2) + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + val expectedState = OtherSessionsViewState( + devices = Success(listOf(deviceFullInfo1.copy(isSelected = false), deviceFullInfo2.copy(isSelected = false))), + currentFilter = defaultArgs.defaultFilter, + excludeCurrentDevice = defaultArgs.excludeCurrentDevice, + isSelectModeEnabled = false, + ) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.DeselectAll) + + // Then + viewModelTest + .assertLatestState { state -> state == expectedState } + .finish() + } + + private fun givenGetDeviceFullInfoListReturns( + filterType: DeviceManagerFilterType, + devices: List, + ) { + every { fakeGetDeviceFullInfoListUseCase.execute(filterType, any()) } returns flowOf(devices) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fixtures/DeviceFullInfoFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/DeviceFullInfoFixture.kt new file mode 100644 index 0000000000..d5f987b5c6 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fixtures/DeviceFullInfoFixture.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 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.fixtures + +import im.vector.app.features.settings.devices.v2.DeviceFullInfo +import im.vector.app.features.settings.devices.v2.details.extended.DeviceExtendedInfo +import im.vector.app.features.settings.devices.v2.list.DeviceType +import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo +import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel + +fun aDeviceFullInfo(deviceId: String, isSelected: Boolean): DeviceFullInfo { + return DeviceFullInfo( + deviceInfo = DeviceInfo( + deviceId = deviceId, + ), + cryptoDeviceInfo = null, + roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted, + isInactive = true, + isCurrentDevice = true, + deviceExtendedInfo = DeviceExtendedInfo( + deviceType = DeviceType.MOBILE, + ), + matrixClientInfo = null, + isSelected = isSelected, + ) +}