Merge pull request #7408 from vector-im/feature/mna/session_manager_multi_selection

[Session manager] Multi selection in sessions list (PSG-852)
This commit is contained in:
Maxime NATUREL 2022-10-26 14:10:27 +02:00 committed by GitHub
commit e8bf79969b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 604 additions and 30 deletions

1
changelog.d/7396.feature Normal file
View file

@ -0,0 +1 @@
Multi selection in sessions list

View file

@ -1,6 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Cross feature -->
<plurals name="x_selected">
<item quantity="one">%1$d selected</item>
<item quantity="other">%1$d selected</item>
</plurals>
<!-- Notice -->
<string name="notice_room_invite_no_invitee">%s\'s invitation</string>
<string name="notice_room_invite_no_invitee_by_you">Your invitation</string>
<string name="notice_room_created">%1$s created the room</string>
@ -407,6 +414,8 @@
<string name="action_learn_more">Learn more</string>
<string name="action_next">Next</string>
<string name="action_got_it">Got it</string>
<string name="action_select_all">Select all</string>
<string name="action_deselect_all">Deselect all</string>
<string name="copied_to_clipboard">Copied to clipboard</string>
@ -3328,6 +3337,7 @@
<string name="device_manager_other_sessions_no_unverified_sessions_found">No unverified sessions found.</string>
<string name="device_manager_other_sessions_no_inactive_sessions_found">No inactive sessions found.</string>
<string name="device_manager_other_sessions_clear_filter">Clear Filter</string>
<string name="device_manager_other_sessions_select">Select sessions</string>
<string name="device_manager_session_overview_signout">Sign out of this session</string>
<string name="device_manager_session_details_title">Session details</string>
<string name="device_manager_session_details_description">Application, device, and activity information.</string>

View file

@ -30,4 +30,5 @@ data class DeviceFullInfo(
val isCurrentDevice: Boolean,
val deviceExtendedInfo: DeviceExtendedInfo,
val matrixClientInfo: MatrixClientInfoContent?,
val isSelected: Boolean = false,
)

View file

@ -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)
}

View file

@ -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<OtherSessionItem.Holder>(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<OtherSessionItem.Holder>(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<OtherSessionItem.Holder>(R.la
val otherSessionVerificationStatusImageView by bind<ShieldImageView>(R.id.otherSessionVerificationStatusImageView)
val otherSessionNameTextView by bind<TextView>(R.id.otherSessionNameTextView)
val otherSessionDescriptionTextView by bind<TextView>(R.id.otherSessionDescriptionTextView)
val otherSessionItemBackgroundView by bind<View>(R.id.otherSessionItemBackground)
}
}

View file

@ -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
})
}
}
}

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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<FragmentOtherSessionsBinding>(),
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<DeviceFullInfo>?, currentFilter: DeviceManagerFilterType) {
private fun updateToolbar(devices: List<DeviceFullInfo>, 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<DeviceFullInfo>, 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() {

View file

@ -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
)
}
}
}

View file

@ -26,6 +26,7 @@ data class OtherSessionsViewState(
val devices: Async<List<DeviceFullInfo>> = Uninitialized,
val currentFilter: DeviceManagerFilterType = DeviceManagerFilterType.ALL_SESSIONS,
val excludeCurrentDevice: Boolean = false,
val isSelectModeEnabled: Boolean = false,
) : MavericksState {
constructor(args: OtherSessionsArgs) : this(excludeCurrentDevice = args.excludeCurrentDevice)

View file

@ -1,7 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="?vctr_system" />
</shape>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape android:shape="oval">
<solid android:color="?attr/vctr_content_primary" />
</shape>
</item>
<item>
<shape android:shape="oval">
<solid android:color="?attr/colorSurface" />
</shape>
</item>
</selector>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/rounded_rect_shape_8" android:state_selected="true" />
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
</shape>
</item>
</selector>

View file

@ -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">
<View
android:id="@+id/otherSessionItemBackground"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/bg_other_session"
app:layout_constraintBottom_toBottomOf="@id/otherSessionVerificationStatusImageView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/otherSessionDeviceTypeImageView"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="11dp"
android:background="@drawable/bg_device_type"
android:contentDescription="@string/a11y_device_manager_device_type_mobile"
android:padding="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="@id/otherSessionItemBackground"
app:layout_constraintStart_toStartOf="@id/otherSessionItemBackground"
app:layout_constraintTop_toTopOf="@id/otherSessionItemBackground"
tools:src="@drawable/ic_device_type_mobile" />
<im.vector.app.core.ui.views.ShieldImageView
android:id="@+id/otherSessionVerificationStatusImageView"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="24dp"
android:layout_marginStart="26dp"
android:layout_marginTop="24dp"
android:background="@drawable/circle_with_border"
android:importantForAccessibility="no"
android:padding="6dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="@id/otherSessionDeviceTypeImageView"
app:layout_constraintTop_toTopOf="@id/otherSessionDeviceTypeImageView"
tools:src="@drawable/ic_shield_trusted" />
<TextView
@ -37,21 +52,23 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:lines="1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/otherSessionDeviceTypeImageView"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintTop_toTopOf="@id/otherSessionDeviceTypeImageView"
tools:text="Element Mobile: Android" />
<TextView
android:id="@+id/otherSessionDescriptionTextView"
style="@style/TextAppearance.Vector.Body.DevicesManagement"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:drawablePadding="8dp"
app:layout_constraintBottom_toBottomOf="@id/otherSessionDeviceTypeImageView"
app:layout_constraintEnd_toEndOf="@id/otherSessionNameTextView"
app:layout_constraintStart_toStartOf="@id/otherSessionNameTextView"
app:layout_constraintTop_toBottomOf="@id/otherSessionNameTextView"
tools:text="@string/device_manager_verification_status_verified" />
@ -59,10 +76,10 @@
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginTop="16dp"
android:layout_marginTop="8dp"
android:background="?vctr_content_quinary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/otherSessionNameTextView"
app:layout_constraintTop_toBottomOf="@id/otherSessionDescriptionTextView" />
app:layout_constraintTop_toBottomOf="@id/otherSessionItemBackground" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -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" />

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="AlwaysShowAction">
<item
android:id="@+id/otherSessionsSelect"
android:title="@string/device_manager_other_sessions_select"
app:showAsAction="withText|never" />
<item
android:id="@+id/otherSessionsSelectAll"
android:title="@string/action_select_all"
app:showAsAction="withText|never" />
<item
android:id="@+id/otherSessionsDeselectAll"
android:title="@string/action_deselect_all"
app:showAsAction="withText|never" />
</menu>

View file

@ -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<GetDeviceFullInfoListUseCase>()
private val fakeRefreshDevicesUseCaseUseCase = mockk<RefreshDevicesUseCase>()
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<T>.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<List<DeviceFullInfo>>()
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<List<DeviceFullInfo>>()
val filteredDevices = mockk<List<DeviceFullInfo>>()
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<DeviceFullInfo> = 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<DeviceFullInfo> = 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<DeviceFullInfo> = 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<DeviceFullInfo> = 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<DeviceFullInfo> = 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<DeviceFullInfo>,
) {
every { fakeGetDeviceFullInfoListUseCase.execute(filterType, any()) } returns flowOf(devices)
}
}

View file

@ -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,
)
}