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:
commit
e8bf79969b
|
@ -0,0 +1 @@
|
|||
Multi selection in sessions list
|
|
@ -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>
|
||||
|
|
|
@ -30,4 +30,5 @@ data class DeviceFullInfo(
|
|||
val isCurrentDevice: Boolean,
|
||||
val deviceExtendedInfo: DeviceExtendedInfo,
|
||||
val matrixClientInfo: MatrixClientInfoContent?,
|
||||
val isSelected: Boolean = false,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,12 +263,22 @@ class OtherSessionsFragment :
|
|||
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(
|
||||
context = requireActivity(),
|
||||
deviceId = deviceId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewAllOtherSessionsClicked() {
|
||||
// NOOP. We don't have this button in this screen
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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" />
|
||||
|
||||
<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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue