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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <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">%s\'s invitation</string>
<string name="notice_room_invite_no_invitee_by_you">Your 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> <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_learn_more">Learn more</string>
<string name="action_next">Next</string> <string name="action_next">Next</string>
<string name="action_got_it">Got it</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> <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_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_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_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_overview_signout">Sign out of this session</string>
<string name="device_manager_session_details_title">Session details</string> <string name="device_manager_session_details_title">Session details</string>
<string name="device_manager_session_details_description">Application, device, and activity information.</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 isCurrentDevice: Boolean,
val deviceExtendedInfo: DeviceExtendedInfo, val deviceExtendedInfo: DeviceExtendedInfo,
val matrixClientInfo: MatrixClientInfoContent?, val matrixClientInfo: MatrixClientInfoContent?,
val isSelected: Boolean = false,
) )

View File

@ -331,6 +331,10 @@ class VectorSettingsDevicesFragment :
views.waitingView.root.isVisible = isLoading views.waitingView.root.isVisible = isLoading
} }
override fun onOtherSessionLongClicked(deviceId: String) {
// do nothing
}
override fun onOtherSessionClicked(deviceId: String) { override fun onOtherSessionClicked(deviceId: String) {
navigateToSessionOverview(deviceId) navigateToSessionOverview(deviceId)
} }

View File

@ -17,6 +17,8 @@
package im.vector.app.features.settings.devices.v2.list package im.vector.app.features.settings.devices.v2.list
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.view.View
import android.view.View.OnLongClickListener
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.annotation.ColorInt 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.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.epoxy.onClick 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.resources.StringProvider
import im.vector.app.core.ui.views.ShieldImageView import im.vector.app.core.ui.views.ShieldImageView
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
@ -56,19 +60,39 @@ abstract class OtherSessionItem : VectorEpoxyModel<OtherSessionItem.Holder>(R.la
@EpoxyAttribute @EpoxyAttribute
lateinit var stringProvider: StringProvider lateinit var stringProvider: StringProvider
@EpoxyAttribute
lateinit var colorProvider: ColorProvider
@EpoxyAttribute
lateinit var drawableProvider: DrawableProvider
@EpoxyAttribute
var selected: Boolean = false
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var clickListener: ClickListener? = null var clickListener: ClickListener? = null
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var onLongClickListener: OnLongClickListener? = null
private val setDeviceTypeIconUseCase = SetDeviceTypeIconUseCase() private val setDeviceTypeIconUseCase = SetDeviceTypeIconUseCase()
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
holder.view.onClick(clickListener) holder.view.onClick(clickListener)
if (clickListener == null) { holder.view.setOnLongClickListener(onLongClickListener)
if (clickListener == null && onLongClickListener == null) {
holder.view.isClickable = false holder.view.isClickable = false
} }
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) setDeviceTypeIconUseCase.execute(deviceType, holder.otherSessionDeviceTypeImageView, stringProvider)
}
holder.otherSessionVerificationStatusImageView.render(roomEncryptionTrustLevel) holder.otherSessionVerificationStatusImageView.render(roomEncryptionTrustLevel)
holder.otherSessionNameTextView.text = sessionName holder.otherSessionNameTextView.text = sessionName
holder.otherSessionDescriptionTextView.text = sessionDescription holder.otherSessionDescriptionTextView.text = sessionDescription
@ -76,6 +100,7 @@ abstract class OtherSessionItem : VectorEpoxyModel<OtherSessionItem.Holder>(R.la
holder.otherSessionDescriptionTextView.setTextColor(it) holder.otherSessionDescriptionTextView.setTextColor(it)
} }
holder.otherSessionDescriptionTextView.setCompoundDrawablesWithIntrinsicBounds(sessionDescriptionDrawable, null, null, null) holder.otherSessionDescriptionTextView.setCompoundDrawablesWithIntrinsicBounds(sessionDescriptionDrawable, null, null, null)
holder.otherSessionItemBackgroundView.isSelected = selected
} }
class Holder : VectorEpoxyHolder() { class Holder : VectorEpoxyHolder() {
@ -83,5 +108,6 @@ abstract class OtherSessionItem : VectorEpoxyModel<OtherSessionItem.Holder>(R.la
val otherSessionVerificationStatusImageView by bind<ShieldImageView>(R.id.otherSessionVerificationStatusImageView) val otherSessionVerificationStatusImageView by bind<ShieldImageView>(R.id.otherSessionVerificationStatusImageView)
val otherSessionNameTextView by bind<TextView>(R.id.otherSessionNameTextView) val otherSessionNameTextView by bind<TextView>(R.id.otherSessionNameTextView)
val otherSessionDescriptionTextView by bind<TextView>(R.id.otherSessionDescriptionTextView) 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 package im.vector.app.features.settings.devices.v2.list
import android.view.View
import com.airbnb.epoxy.TypedEpoxyController import com.airbnb.epoxy.TypedEpoxyController
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.DateFormatKind
@ -38,6 +39,7 @@ class OtherSessionsController @Inject constructor(
var callback: Callback? = null var callback: Callback? = null
interface Callback { interface Callback {
fun onItemLongClicked(deviceId: String)
fun onItemClicked(deviceId: String) fun onItemClicked(deviceId: String)
} }
@ -70,8 +72,15 @@ class OtherSessionsController @Inject constructor(
sessionDescription(description) sessionDescription(description)
sessionDescriptionDrawable(descriptionDrawable) sessionDescriptionDrawable(descriptionDrawable)
sessionDescriptionColor(descriptionColor) 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) } } 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 { ) : ConstraintLayout(context, attrs, defStyleAttr), OtherSessionsController.Callback {
interface Callback { interface Callback {
fun onOtherSessionLongClicked(deviceId: String)
fun onOtherSessionClicked(deviceId: String) fun onOtherSessionClicked(deviceId: String)
fun onViewAllOtherSessionsClicked() fun onViewAllOtherSessionsClicked()
} }
@ -107,4 +108,8 @@ class OtherSessionsView @JvmOverloads constructor(
override fun onItemClicked(deviceId: String) { override fun onItemClicked(deviceId: String) {
callback?.onOtherSessionClicked(deviceId) 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 { sealed class OtherSessionsAction : VectorViewModelAction {
data class FilterDevices(val filterType: DeviceManagerFilterType) : OtherSessionsAction() 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.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.activity.addCallback
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.mvrx.Success 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
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment.ResultListener.Companion.RESULT_OK import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment.ResultListener.Companion.RESULT_OK
import im.vector.app.core.platform.VectorBaseFragment 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.ColorProvider
import im.vector.app.core.resources.StringProvider
import im.vector.app.databinding.FragmentOtherSessionsBinding import im.vector.app.databinding.FragmentOtherSessionsBinding
import im.vector.app.features.settings.devices.v2.DeviceFullInfo import im.vector.app.features.settings.devices.v2.DeviceFullInfo
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterBottomSheet 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.list.SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS
import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet
import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.themes.ThemeUtils
import org.matrix.android.sdk.api.extensions.orFalse
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class OtherSessionsFragment : class OtherSessionsFragment :
VectorBaseFragment<FragmentOtherSessionsBinding>(), VectorBaseFragment<FragmentOtherSessionsBinding>(),
VectorBaseBottomSheetDialogFragment.ResultListener, VectorBaseBottomSheetDialogFragment.ResultListener,
OtherSessionsView.Callback { OtherSessionsView.Callback,
VectorMenuProvider {
private val viewModel: OtherSessionsViewModel by fragmentViewModel() private val viewModel: OtherSessionsViewModel by fragmentViewModel()
private val args: OtherSessionsArgs by args() private val args: OtherSessionsArgs by args()
@Inject lateinit var colorProvider: ColorProvider @Inject lateinit var colorProvider: ColorProvider
@Inject lateinit var stringProvider: StringProvider
@Inject lateinit var viewNavigator: OtherSessionsViewNavigator @Inject lateinit var viewNavigator: OtherSessionsViewNavigator
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentOtherSessionsBinding { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentOtherSessionsBinding {
return FragmentOtherSessionsBinding.inflate(layoutInflater, container, false) 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
setupToolbar(views.otherSessionsToolbar).setTitle(args.titleResourceId).allowBack() setupToolbar(views.otherSessionsToolbar).setTitle(args.titleResourceId).allowBack()
@ -103,11 +163,24 @@ class OtherSessionsFragment :
override fun invalidate() = withState(viewModel) { state -> override fun invalidate() = withState(viewModel) { state ->
if (state.devices is Success) { 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.otherSessionsFilterBadgeImageView.isVisible = currentFilter != DeviceManagerFilterType.ALL_SESSIONS
views.otherSessionsSecurityRecommendationView.isVisible = currentFilter != DeviceManagerFilterType.ALL_SESSIONS views.otherSessionsSecurityRecommendationView.isVisible = currentFilter != DeviceManagerFilterType.ALL_SESSIONS
views.deviceListHeaderOtherSessions.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.deviceListOtherSessions.isVisible = false
views.otherSessionsNotFoundLayout.isVisible = true views.otherSessionsNotFoundLayout.isVisible = true
} else { } else {
@ -190,12 +263,22 @@ class OtherSessionsFragment :
SessionLearnMoreBottomSheet.show(childFragmentManager, args) SessionLearnMoreBottomSheet.show(childFragmentManager, args)
} }
override fun onOtherSessionClicked(deviceId: String) { 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( viewNavigator.navigateToSessionOverview(
context = requireActivity(), context = requireActivity(),
deviceId = deviceId deviceId = deviceId
) )
} }
}
override fun onViewAllOtherSessionsClicked() { override fun onViewAllOtherSessionsClicked() {
// NOOP. We don't have this button in this screen // NOOP. We don't have this button in this screen

View File

@ -17,6 +17,7 @@
package im.vector.app.features.settings.devices.v2.othersessions package im.vector.app.features.settings.devices.v2.othersessions
import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.Success
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -67,6 +68,11 @@ class OtherSessionsViewModel @AssistedInject constructor(
override fun handle(action: OtherSessionsAction) { override fun handle(action: OtherSessionsAction) {
when (action) { when (action) {
is OtherSessionsAction.FilterDevices -> handleFilterDevices(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) 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 devices: Async<List<DeviceFullInfo>> = Uninitialized,
val currentFilter: DeviceManagerFilterType = DeviceManagerFilterType.ALL_SESSIONS, val currentFilter: DeviceManagerFilterType = DeviceManagerFilterType.ALL_SESSIONS,
val excludeCurrentDevice: Boolean = false, val excludeCurrentDevice: Boolean = false,
val isSelectModeEnabled: Boolean = false,
) : MavericksState { ) : MavericksState {
constructor(args: OtherSessionsArgs) : this(excludeCurrentDevice = args.excludeCurrentDevice) constructor(args: OtherSessionsArgs) : this(excludeCurrentDevice = args.excludeCurrentDevice)

View File

@ -1,7 +1,13 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" <selector xmlns:android="http://schemas.android.com/apk/res/android">
android:shape="oval"> <item android:state_selected="true">
<shape android:shape="oval">
<solid android:color="?vctr_system" /> <solid android:color="?attr/vctr_content_primary" />
</shape>
</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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:foreground="?selectableItemBackground" 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 <ImageView
android:id="@+id/otherSessionDeviceTypeImageView" android:id="@+id/otherSessionDeviceTypeImageView"
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="40dp" android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="11dp"
android:background="@drawable/bg_device_type" android:background="@drawable/bg_device_type"
android:contentDescription="@string/a11y_device_manager_device_type_mobile" android:contentDescription="@string/a11y_device_manager_device_type_mobile"
android:padding="8dp" android:padding="8dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toBottomOf="@id/otherSessionItemBackground"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="@id/otherSessionItemBackground"
app:layout_constraintTop_toTopOf="@id/otherSessionItemBackground"
tools:src="@drawable/ic_device_type_mobile" /> tools:src="@drawable/ic_device_type_mobile" />
<im.vector.app.core.ui.views.ShieldImageView <im.vector.app.core.ui.views.ShieldImageView
android:id="@+id/otherSessionVerificationStatusImageView" android:id="@+id/otherSessionVerificationStatusImageView"
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" android:layout_height="24dp"
android:layout_marginStart="24dp" android:layout_marginStart="26dp"
android:layout_marginTop="24dp" android:layout_marginTop="24dp"
android:background="@drawable/circle_with_border" android:background="@drawable/circle_with_border"
android:importantForAccessibility="no" android:importantForAccessibility="no"
android:padding="6dp" android:padding="6dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="@id/otherSessionDeviceTypeImageView"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="@id/otherSessionDeviceTypeImageView"
tools:src="@drawable/ic_shield_trusted" /> tools:src="@drawable/ic_shield_trusted" />
<TextView <TextView
@ -37,21 +52,23 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
android:layout_marginEnd="16dp" android:layout_marginEnd="8dp"
android:ellipsize="end" android:ellipsize="end"
android:lines="1" android:lines="1"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/otherSessionDeviceTypeImageView" app:layout_constraintStart_toEndOf="@id/otherSessionDeviceTypeImageView"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="@id/otherSessionDeviceTypeImageView"
tools:text="Element Mobile: Android" /> tools:text="Element Mobile: Android" />
<TextView <TextView
android:id="@+id/otherSessionDescriptionTextView" android:id="@+id/otherSessionDescriptionTextView"
style="@style/TextAppearance.Vector.Body.DevicesManagement" style="@style/TextAppearance.Vector.Body.DevicesManagement"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:layout_marginTop="2dp"
android:drawablePadding="8dp" android:drawablePadding="8dp"
app:layout_constraintBottom_toBottomOf="@id/otherSessionDeviceTypeImageView"
app:layout_constraintEnd_toEndOf="@id/otherSessionNameTextView"
app:layout_constraintStart_toStartOf="@id/otherSessionNameTextView" app:layout_constraintStart_toStartOf="@id/otherSessionNameTextView"
app:layout_constraintTop_toBottomOf="@id/otherSessionNameTextView" app:layout_constraintTop_toBottomOf="@id/otherSessionNameTextView"
tools:text="@string/device_manager_verification_status_verified" /> tools:text="@string/device_manager_verification_status_verified" />
@ -59,10 +76,10 @@
<View <View
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginTop="16dp" android:layout_marginTop="8dp"
android:background="?vctr_content_quinary" android:background="?vctr_content_quinary"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/otherSessionNameTextView" app:layout_constraintStart_toStartOf="@id/otherSessionNameTextView"
app:layout_constraintTop_toBottomOf="@id/otherSessionDescriptionTextView" /> app:layout_constraintTop_toBottomOf="@id/otherSessionItemBackground" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -9,7 +9,6 @@
android:id="@+id/otherSessionsRecyclerView" android:id="@+id/otherSessionsRecyclerView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
@ -21,6 +20,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="0dp" android:padding="0dp"
android:layout_marginStart="16dp"
app:layout_constraintStart_toStartOf="@id/otherSessionsRecyclerView" app:layout_constraintStart_toStartOf="@id/otherSessionsRecyclerView"
app:layout_constraintTop_toBottomOf="@id/otherSessionsRecyclerView" app:layout_constraintTop_toBottomOf="@id/otherSessionsRecyclerView"
tools:text="@string/device_manager_other_sessions_view_all" /> 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,
)
}