Merge branch 'develop' into feature/ons/fix_device_manager_verified_desc

This commit is contained in:
Onuray Sahin 2022-11-09 19:05:07 +03:00
commit b2589a1e4d
52 changed files with 1354 additions and 415 deletions

View file

@ -60,8 +60,8 @@ jobs:
headers: '{"GraphQL-Features": "projects_next_graphql"}'
query: |
mutation add_to_project($projectid:ID!, $contentid:ID!) {
addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) {
projectNextItem {
addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) {
item {
id
}
}
@ -129,8 +129,8 @@ jobs:
headers: '{"GraphQL-Features": "projects_next_graphql"}'
query: |
mutation add_to_project($projectid:ID!, $contentid:ID!) {
addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) {
projectNextItem {
addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) {
item {
id
}
}

View file

@ -1,3 +1,10 @@
Changes in Element v1.5.7 (2022-11-07)
======================================
Bugfixes 🐛
----------
- Fix regression when syncing with homeserver < 1.4. ([#7534](https://github.com/vector-im/element-android/issues/7534))
Changes in Element v1.5.6 (2022-11-02)
======================================

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

@ -0,0 +1 @@
[Session manager] Multi-session signout

1
changelog.d/7501.bugfix Normal file
View file

@ -0,0 +1 @@
Fix duplicated mention pills in some cases

1
changelog.d/7509.bugfix Normal file
View file

@ -0,0 +1 @@
When joining a room, the message composer is displayed once the room is loaded.

View file

@ -26,7 +26,7 @@ def jjwt = "0.11.5"
// Temporary version to unblock #6929. Once 0.16.0 is released we should use it, and revert
// the whole commit which set version 0.16.0-SNAPSHOT
def vanniktechEmoji = "0.16.0-SNAPSHOT"
def sentry = "6.6.0"
def sentry = "6.7.0"
def fragment = "1.5.4"
// Testing
def mockk = "1.12.3" // We need to use 1.12.3 to have mocking in androidTest until a new version is released: https://github.com/mockk/mockk/issues/819

View file

@ -0,0 +1,2 @@
Main changes in this version: new UI for selecting an attachment.
Full changelog: https://github.com/vector-im/element-android/releases

View file

@ -3345,6 +3345,11 @@
<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_other_sessions_multi_signout_selection">Sign out</string>
<plurals name="device_manager_other_sessions_multi_signout_all">
<item quantity="one">Sign out of %1$d session</item>
<item quantity="other">Sign out of %1$d sessions</item>
</plurals>
<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

@ -5,6 +5,7 @@
<attr name="sessionsListHeaderTitle" format="string" />
<attr name="sessionsListHeaderDescription" format="string" />
<attr name="sessionsListHeaderHasLearnMoreLink" format="boolean" />
<attr name="sessionsListHeaderMenu" format="reference" />
</declare-styleable>
</resources>

View file

@ -16,6 +16,7 @@
package org.matrix.android.sdk.session.search
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Assert.assertTrue
import org.junit.FixMethodOrder
import org.junit.Test
@ -43,7 +44,7 @@ class SearchMessagesTest : InstrumentedTest {
cryptoTestData.firstSession
.searchService()
.search(
searchTerm = "lore",
searchTerm = "lorem",
limit = 10,
includeProfile = true,
afterLimit = 0,
@ -61,7 +62,7 @@ class SearchMessagesTest : InstrumentedTest {
cryptoTestData.firstSession
.searchService()
.search(
searchTerm = "lore",
searchTerm = "lorem",
roomId = cryptoTestData.roomId,
limit = 10,
includeProfile = true,
@ -73,7 +74,28 @@ class SearchMessagesTest : InstrumentedTest {
}
}
private fun doTest(block: suspend (CryptoTestData) -> SearchResult) = runCryptoTest(context()) { cryptoTestHelper, commonTestHelper ->
@Test
fun sendTextMessageAndSearchPartOfItIncompleteWord() {
doTest(expectedNumberOfResult = 0) { cryptoTestData ->
cryptoTestData.firstSession
.searchService()
.search(
searchTerm = "lore", /* incomplete word */
roomId = cryptoTestData.roomId,
limit = 10,
includeProfile = true,
afterLimit = 0,
beforeLimit = 10,
orderByRecent = true,
nextBatch = null
)
}
}
private fun doTest(
expectedNumberOfResult: Int = 2,
block: suspend (CryptoTestData) -> SearchResult,
) = runCryptoTest(context()) { cryptoTestHelper, commonTestHelper ->
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceInARoom(false)
val aliceSession = cryptoTestData.firstSession
val aliceRoomId = cryptoTestData.roomId
@ -87,7 +109,7 @@ class SearchMessagesTest : InstrumentedTest {
val data = block.invoke(cryptoTestData)
assertTrue(data.results?.size == 2)
data.results?.size shouldBeEqualTo expectedNumberOfResult
assertTrue(
data.results
?.all {

View file

@ -17,6 +17,7 @@
package org.matrix.android.sdk.api.session.crypto
import android.content.Context
import androidx.annotation.Size
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import org.matrix.android.sdk.api.MatrixCallback
@ -55,6 +56,8 @@ interface CryptoService {
fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback<Unit>)
fun deleteDevices(@Size(min = 1) deviceIds: List<String>, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback<Unit>)
fun getCryptoVersion(context: Context, longFormat: Boolean): String
fun isCryptoEnabled(): Boolean

View file

@ -242,8 +242,12 @@ internal class DefaultCryptoService @Inject constructor(
}
override fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback<Unit>) {
deleteDevices(listOf(deviceId), userInteractiveAuthInterceptor, callback)
}
override fun deleteDevices(deviceIds: List<String>, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback<Unit>) {
deleteDeviceTask
.configureWith(DeleteDeviceTask.Params(deviceId, userInteractiveAuthInterceptor, null)) {
.configureWith(DeleteDeviceTask.Params(deviceIds, userInteractiveAuthInterceptor, null)) {
this.executionThread = TaskThread.CRYPTO
this.callback = callback
}

View file

@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.crypto.api
import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.DevicesListResponse
import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams
import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDevicesParams
import org.matrix.android.sdk.internal.crypto.model.rest.KeyChangesResponse
import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimBody
import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimResponse
@ -136,6 +137,17 @@ internal interface CryptoApi {
@Body params: DeleteDeviceParams
)
/**
* Deletes the given devices, and invalidates any access token associated with them.
* Doc: https://spec.matrix.org/v1.4/client-server-api/#post_matrixclientv3delete_devices
*
* @param params the deletion parameters
*/
@POST(NetworkConstants.URI_API_PREFIX_PATH_V3 + "delete_devices")
suspend fun deleteDevices(
@Body params: DeleteDevicesParams
)
/**
* Update the device information.
* Doc: https://matrix.org/docs/spec/client_server/r0.4.0.html#put-matrix-client-r0-devices-deviceid

View file

@ -23,6 +23,9 @@ import com.squareup.moshi.JsonClass
*/
@JsonClass(generateAdapter = true)
internal data class DeleteDeviceParams(
/**
* Additional authentication information for the user-interactive authentication API.
*/
@Json(name = "auth")
val auth: Map<String, *>? = null
val auth: Map<String, *>? = null,
)

View file

@ -0,0 +1,37 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.crypto.model.rest
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* This class provides the parameter to delete several devices.
*/
@JsonClass(generateAdapter = true)
internal data class DeleteDevicesParams(
/**
* Additional authentication information for the user-interactive authentication API.
*/
@Json(name = "auth")
val auth: Map<String, *>? = null,
/**
* Required: The list of device IDs to delete.
*/
@Json(name = "devices")
val deviceIds: List<String>,
)

View file

@ -16,12 +16,14 @@
package org.matrix.android.sdk.internal.crypto.tasks
import androidx.annotation.Size
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.session.uia.UiaResult
import org.matrix.android.sdk.internal.auth.registration.handleUIA
import org.matrix.android.sdk.internal.crypto.api.CryptoApi
import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams
import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDevicesParams
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.task.Task
@ -30,7 +32,7 @@ import javax.inject.Inject
internal interface DeleteDeviceTask : Task<DeleteDeviceTask.Params, Unit> {
data class Params(
val deviceId: String,
@Size(min = 1) val deviceIds: List<String>,
val userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor?,
val userAuthParam: UIABaseAuth?
)
@ -42,9 +44,24 @@ internal class DefaultDeleteDeviceTask @Inject constructor(
) : DeleteDeviceTask {
override suspend fun execute(params: DeleteDeviceTask.Params) {
require(params.deviceIds.isNotEmpty())
try {
executeRequest(globalErrorReceiver) {
cryptoApi.deleteDevice(params.deviceId, DeleteDeviceParams(params.userAuthParam?.asMap()))
val userAuthParam = params.userAuthParam?.asMap()
if (params.deviceIds.size == 1) {
cryptoApi.deleteDevice(
deviceId = params.deviceIds.first(),
DeleteDeviceParams(auth = userAuthParam)
)
} else {
cryptoApi.deleteDevices(
DeleteDevicesParams(
auth = userAuthParam,
deviceIds = params.deviceIds
)
)
}
}
} catch (throwable: Throwable) {
if (params.userInteractiveAuthInterceptor == null ||

View file

@ -22,6 +22,7 @@ internal object NetworkConstants {
const val URI_API_PREFIX_PATH_ = "$URI_API_PREFIX_PATH/"
const val URI_API_PREFIX_PATH_R0 = "$URI_API_PREFIX_PATH/r0/"
const val URI_API_PREFIX_PATH_V1 = "$URI_API_PREFIX_PATH/v1/"
const val URI_API_PREFIX_PATH_V3 = "$URI_API_PREFIX_PATH/v3/"
const val URI_API_PREFIX_PATH_UNSTABLE = "$URI_API_PREFIX_PATH/unstable/"
// Media

View file

@ -67,7 +67,9 @@ internal object FilterFactory {
}
private fun createElementTimelineFilter(): RoomEventFilter? {
return RoomEventFilter(enableUnreadThreadNotifications = true)
// we need to check if homeserver supports thread notifications before setting this param
// return RoomEventFilter(enableUnreadThreadNotifications = true)
return null
}
private fun createElementStateFilter(): RoomEventFilter {

View file

@ -132,7 +132,7 @@ dependencies {
implementation libs.androidx.biometric
api "org.threeten:threetenbp:1.4.0:no-tzdb"
api "com.gabrielittner.threetenbp:lazythreetenbp:0.11.0"
api "com.gabrielittner.threetenbp:lazythreetenbp:0.12.0"
implementation libs.squareup.moshi
kapt libs.squareup.moshiKotlin

View file

@ -0,0 +1,29 @@
/*
* 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.core.extensions
import android.view.MenuItem
import androidx.annotation.ColorInt
import androidx.core.text.toSpannable
import im.vector.app.core.utils.colorizeMatchingText
fun MenuItem.setTextColor(@ColorInt color: Int) {
val currentTitle = title.orEmpty().toString()
title = currentTitle
.toSpannable()
.colorizeMatchingText(currentTitle, color)
}

View file

@ -24,26 +24,6 @@ package im.vector.app.features.analytics.plan
* definition. These properties must all be device independent.
*/
data class UserProperties(
/**
* Whether the user has the favourites space enabled.
*/
val webMetaSpaceFavouritesEnabled: Boolean? = null,
/**
* Whether the user has the home space set to all rooms.
*/
val webMetaSpaceHomeAllRooms: Boolean? = null,
/**
* Whether the user has the home space enabled.
*/
val webMetaSpaceHomeEnabled: Boolean? = null,
/**
* Whether the user has the other rooms space enabled.
*/
val webMetaSpaceOrphansEnabled: Boolean? = null,
/**
* Whether the user has the people space enabled.
*/
val webMetaSpacePeopleEnabled: Boolean? = null,
/**
* The active filter in the All Chats screen.
*/
@ -109,11 +89,6 @@ data class UserProperties(
fun getProperties(): Map<String, Any>? {
return mutableMapOf<String, Any>().apply {
webMetaSpaceFavouritesEnabled?.let { put("WebMetaSpaceFavouritesEnabled", it) }
webMetaSpaceHomeAllRooms?.let { put("WebMetaSpaceHomeAllRooms", it) }
webMetaSpaceHomeEnabled?.let { put("WebMetaSpaceHomeEnabled", it) }
webMetaSpaceOrphansEnabled?.let { put("WebMetaSpaceOrphansEnabled", it) }
webMetaSpacePeopleEnabled?.let { put("WebMetaSpacePeopleEnabled", it) }
allChatsActiveFilter?.let { put("allChatsActiveFilter", it.name) }
ftueUseCaseSelection?.let { put("ftueUseCaseSelection", it.name) }
numFavouriteRooms?.let { put("numFavouriteRooms", it) }

View file

@ -1169,6 +1169,9 @@ class TimelineFragment :
lazyLoadedViews.inviteView(false)?.isVisible = false
if (mainState.tombstoneEvent == null) {
views.composerContainer.isInvisible = !messageComposerState.isComposerVisible
views.voiceMessageRecorderContainer.isVisible = messageComposerState.isVoiceMessageRecorderVisible
when (messageComposerState.canSendMessage) {
CanSendStatus.Allowed -> {
NotificationAreaView.State.Hidden
@ -1224,6 +1227,7 @@ class TimelineFragment :
private fun FragmentTimelineBinding.hideComposerViews() {
composerContainer.isVisible = false
voiceMessageRecorderContainer.isVisible = false
}
private fun renderTypingMessageNotification(roomSummary: RoomSummary?, state: RoomDetailViewState) {

View file

@ -83,6 +83,20 @@ class PillsPostProcessor @AssistedInject constructor(
val pillSpan = linkSpan.createPillSpan(roomId) ?: return@forEach
val startSpan = renderedText.getSpanStart(linkSpan)
val endSpan = renderedText.getSpanEnd(linkSpan)
// GlideImagesPlugin causes duplicated pills if we have a nested spans in the pill span,
// such as images or italic text.
// Accordingly, it's better to remove all spans that are contained in this span before rendering.
renderedText.getSpans(startSpan, endSpan, Any::class.java).forEach remove@{
if (it !is LinkSpan) {
// Make sure to only remove spans that are contained in this link, and not are bigger than this link, e.g. like reply-blocks
val start = renderedText.getSpanStart(it)
if (start < startSpan) return@remove
val end = renderedText.getSpanEnd(it)
if (end > endSpan) return@remove
renderedText.removeSpan(it)
}
}
addPillSpan(renderedText, pillSpan, startSpan, endSpan)
}
}

View file

@ -20,6 +20,13 @@ import im.vector.app.core.platform.VectorViewModelAction
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
sealed class DevicesAction : VectorViewModelAction {
// ReAuth
object SsoAuthDone : DevicesAction()
data class PasswordAuthDone(val password: String) : DevicesAction()
object ReAuthCancelled : DevicesAction()
// Others
object VerifyCurrentSession : DevicesAction()
data class MarkAsManuallyVerified(val cryptoDeviceInfo: CryptoDeviceInfo) : DevicesAction()
object MultiSignoutOtherSessions : DevicesAction()
}

View file

@ -19,15 +19,17 @@ package im.vector.app.features.settings.devices.v2
import im.vector.app.core.platform.VectorViewEvents
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
sealed class DevicesViewEvent : VectorViewEvents {
data class Loading(val message: CharSequence? = null) : DevicesViewEvent()
data class Failure(val throwable: Throwable) : DevicesViewEvent()
data class RequestReAuth(val registrationFlowResponse: RegistrationFlowResponse, val lastErrorCode: String?) : DevicesViewEvent()
data class PromptRenameDevice(val deviceInfo: DeviceInfo) : DevicesViewEvent()
data class RequestReAuth(
val registrationFlowResponse: RegistrationFlowResponse,
val lastErrorCode: String?
) : DevicesViewEvent()
data class ShowVerifyDevice(val userId: String, val transactionId: String?) : DevicesViewEvent()
object SelfVerification : DevicesViewEvent()
data class ShowManuallyVerify(val cryptoDeviceInfo: CryptoDeviceInfo) : DevicesViewEvent()
object PromptResetSecrets : DevicesViewEvent()
object SignoutSuccess : DevicesViewEvent()
data class SignoutError(val error: Throwable) : DevicesViewEvent()
}

View file

@ -24,13 +24,19 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.features.auth.PendingAuthHandler
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType
import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase
import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
import timber.log.Timber
class DevicesViewModel @AssistedInject constructor(
@Assisted initialState: DevicesViewState,
@ -39,6 +45,9 @@ class DevicesViewModel @AssistedInject constructor(
private val getDeviceFullInfoListUseCase: GetDeviceFullInfoListUseCase,
private val refreshDevicesOnCryptoDevicesChangeUseCase: RefreshDevicesOnCryptoDevicesChangeUseCase,
private val checkIfCurrentSessionCanBeVerifiedUseCase: CheckIfCurrentSessionCanBeVerifiedUseCase,
private val signoutSessionsUseCase: SignoutSessionsUseCase,
private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase,
private val pendingAuthHandler: PendingAuthHandler,
refreshDevicesUseCase: RefreshDevicesUseCase,
) : VectorSessionsListViewModel<DevicesViewState, DevicesAction, DevicesViewEvent>(initialState, activeSessionHolder, refreshDevicesUseCase) {
@ -97,8 +106,12 @@ class DevicesViewModel @AssistedInject constructor(
override fun handle(action: DevicesAction) {
when (action) {
is DevicesAction.PasswordAuthDone -> handlePasswordAuthDone(action)
DevicesAction.ReAuthCancelled -> handleReAuthCancelled()
DevicesAction.SsoAuthDone -> handleSsoAuthDone()
is DevicesAction.VerifyCurrentSession -> handleVerifyCurrentSessionAction()
is DevicesAction.MarkAsManuallyVerified -> handleMarkAsManuallyVerifiedAction()
DevicesAction.MultiSignoutOtherSessions -> handleMultiSignoutOtherSessions()
}
}
@ -116,4 +129,66 @@ class DevicesViewModel @AssistedInject constructor(
private fun handleMarkAsManuallyVerifiedAction() {
// TODO implement when needed
}
private fun handleMultiSignoutOtherSessions() = withState { state ->
viewModelScope.launch {
setLoading(true)
val deviceIds = getDeviceIdsOfOtherSessions(state)
if (deviceIds.isEmpty()) {
return@launch
}
val result = signout(deviceIds)
setLoading(false)
val error = result.exceptionOrNull()
if (error == null) {
onSignoutSuccess()
} else {
onSignoutFailure(error)
}
}
}
private fun getDeviceIdsOfOtherSessions(state: DevicesViewState): List<String> {
val currentDeviceId = state.currentSessionCrossSigningInfo.deviceId
return state.devices()
?.mapNotNull { fullInfo -> fullInfo.deviceInfo.deviceId.takeUnless { it == currentDeviceId } }
.orEmpty()
}
private suspend fun signout(deviceIds: List<String>) = signoutSessionsUseCase.execute(deviceIds, this::onReAuthNeeded)
private fun onReAuthNeeded(reAuthNeeded: SignoutSessionsReAuthNeeded) {
Timber.d("onReAuthNeeded")
pendingAuthHandler.pendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session)
pendingAuthHandler.uiaContinuation = reAuthNeeded.uiaContinuation
_viewEvents.post(DevicesViewEvent.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode))
}
private fun setLoading(isLoading: Boolean) {
setState { copy(isLoading = isLoading) }
}
private fun onSignoutSuccess() {
Timber.d("signout success")
refreshDeviceList()
_viewEvents.post(DevicesViewEvent.SignoutSuccess)
}
private fun onSignoutFailure(failure: Throwable) {
Timber.e("signout failure", failure)
_viewEvents.post(DevicesViewEvent.SignoutError(failure))
}
private fun handleSsoAuthDone() {
pendingAuthHandler.ssoAuthDone()
}
private fun handlePasswordAuthDone(action: DevicesAction.PasswordAuthDone) {
pendingAuthHandler.passwordAuthDone(action.password)
}
private fun handleReAuthCancelled() {
pendingAuthHandler.reAuthCancelled()
}
}

View file

@ -16,6 +16,7 @@
package im.vector.app.features.settings.devices.v2
import android.app.Activity
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
@ -30,12 +31,15 @@ import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.dialogs.ManuallyVerifyDialog
import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.extensions.setTextColor
import im.vector.app.core.platform.VectorBaseFragment
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.databinding.FragmentSettingsDevicesBinding
import im.vector.app.features.VectorFeatures
import im.vector.app.features.auth.ReAuthActivity
import im.vector.app.features.crypto.recover.SetupMode
import im.vector.app.features.crypto.verification.VerificationBottomSheet
import im.vector.app.features.login.qr.QrCodeLoginArgs
@ -47,6 +51,8 @@ import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INAC
import im.vector.app.features.settings.devices.v2.list.SecurityRecommendationView
import im.vector.app.features.settings.devices.v2.list.SecurityRecommendationViewState
import im.vector.app.features.settings.devices.v2.list.SessionInfoViewState
import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import javax.inject.Inject
@ -70,6 +76,8 @@ class VectorSettingsDevicesFragment :
@Inject lateinit var stringProvider: StringProvider
@Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase
private val viewModel: DevicesViewModel by fragmentViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSettingsDevicesBinding {
@ -91,6 +99,7 @@ class VectorSettingsDevicesFragment :
super.onViewCreated(view, savedInstanceState)
initWaitingView()
initOtherSessionsHeaderView()
initOtherSessionsView()
initSecurityRecommendationsView()
initQrLoginView()
@ -100,10 +109,7 @@ class VectorSettingsDevicesFragment :
private fun observeViewEvents() {
viewModel.observeViewEvents {
when (it) {
is DevicesViewEvent.Loading -> showLoading(it.message)
is DevicesViewEvent.Failure -> showFailure(it.throwable)
is DevicesViewEvent.RequestReAuth -> Unit // TODO. Next PR
is DevicesViewEvent.PromptRenameDevice -> Unit // TODO. Next PR
is DevicesViewEvent.RequestReAuth -> askForReAuthentication(it)
is DevicesViewEvent.ShowVerifyDevice -> {
VerificationBottomSheet.withArgs(
roomId = null,
@ -122,6 +128,8 @@ class VectorSettingsDevicesFragment :
is DevicesViewEvent.PromptResetSecrets -> {
navigator.open4SSetup(requireActivity(), SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET)
}
is DevicesViewEvent.SignoutError -> showFailure(it.error)
is DevicesViewEvent.SignoutSuccess -> Unit // do nothing
}
}
}
@ -131,6 +139,29 @@ class VectorSettingsDevicesFragment :
views.waitingView.waitingStatusText.isVisible = true
}
private fun initOtherSessionsHeaderView() {
views.deviceListHeaderOtherSessions.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.otherSessionsHeaderMultiSignout -> {
confirmMultiSignoutOtherSessions()
true
}
else -> false
}
}
}
private fun confirmMultiSignoutOtherSessions() {
activity?.let {
buildConfirmSignoutDialogUseCase.execute(it, this::multiSignoutOtherSessions)
.show()
}
}
private fun multiSignoutOtherSessions() {
viewModel.handle(DevicesAction.MultiSignoutOtherSessions)
}
private fun initOtherSessionsView() {
views.deviceListOtherSessions.callback = this
}
@ -142,7 +173,7 @@ class VectorSettingsDevicesFragment :
requireActivity(),
R.string.device_manager_header_section_security_recommendations_title,
DeviceManagerFilterType.UNVERIFIED,
excludeCurrentDevice = false
excludeCurrentDevice = true
)
}
}
@ -152,7 +183,7 @@ class VectorSettingsDevicesFragment :
requireActivity(),
R.string.device_manager_header_section_security_recommendations_title,
DeviceManagerFilterType.INACTIVE,
excludeCurrentDevice = false
excludeCurrentDevice = true
)
}
}
@ -271,6 +302,11 @@ class VectorSettingsDevicesFragment :
hideOtherSessionsView()
} else {
views.deviceListHeaderOtherSessions.isVisible = true
val color = colorProvider.getColorFromAttribute(R.attr.colorError)
val multiSignoutItem = views.deviceListHeaderOtherSessions.menu.findItem(R.id.otherSessionsHeaderMultiSignout)
val nbDevices = otherDevices.size
multiSignoutItem.title = stringProvider.getQuantityString(R.plurals.device_manager_other_sessions_multi_signout_all, nbDevices, nbDevices)
multiSignoutItem.setTextColor(color)
views.deviceListOtherSessions.isVisible = true
views.deviceListOtherSessions.render(
devices = otherDevices.take(NUMBER_OF_OTHER_DEVICES_TO_RENDER),
@ -347,4 +383,37 @@ class VectorSettingsDevicesFragment :
excludeCurrentDevice = true
)
}
private val reAuthActivityResultLauncher = registerStartForActivityResult { activityResult ->
if (activityResult.resultCode == Activity.RESULT_OK) {
when (activityResult.data?.extras?.getString(ReAuthActivity.RESULT_FLOW_TYPE)) {
LoginFlowTypes.SSO -> {
viewModel.handle(DevicesAction.SsoAuthDone)
}
LoginFlowTypes.PASSWORD -> {
val password = activityResult.data?.extras?.getString(ReAuthActivity.RESULT_VALUE) ?: ""
viewModel.handle(DevicesAction.PasswordAuthDone(password))
}
else -> {
viewModel.handle(DevicesAction.ReAuthCancelled)
}
}
} else {
viewModel.handle(DevicesAction.ReAuthCancelled)
}
}
/**
* Launch the re auth activity to get credentials.
*/
private fun askForReAuthentication(reAuthReq: DevicesViewEvent.RequestReAuth) {
ReAuthActivity.newIntent(
requireContext(),
reAuthReq.registrationFlowResponse,
reAuthReq.lastErrorCode,
getString(R.string.devices_delete_dialog_title)
).let { intent ->
reAuthActivityResultLauncher.launch(intent)
}
}
}

View file

@ -20,6 +20,10 @@ import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.widget.ActionMenuView.OnMenuItemClickListener
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.use
import androidx.core.view.isVisible
@ -39,6 +43,7 @@ class SessionsListHeaderView @JvmOverloads constructor(
this
)
val menu: Menu = binding.sessionsListHeaderMenu.menu
var onLearnMoreClickListener: (() -> Unit)? = null
init {
@ -50,6 +55,7 @@ class SessionsListHeaderView @JvmOverloads constructor(
).use {
setTitle(it)
setDescription(it)
setMenu(it)
}
}
@ -90,4 +96,19 @@ class SessionsListHeaderView @JvmOverloads constructor(
onLearnMoreClickListener?.invoke()
}
}
private fun setMenu(typedArray: TypedArray) {
val menuResId = typedArray.getResourceId(R.styleable.SessionsListHeaderView_sessionsListHeaderMenu, -1)
if (menuResId == -1) {
binding.sessionsListHeaderMenu.isVisible = false
} else {
binding.sessionsListHeaderMenu.showOverflowMenu()
val menuBuilder = binding.sessionsListHeaderMenu.menu as? MenuBuilder
menuBuilder?.let { MenuInflater(context).inflate(menuResId, it) }
}
}
fun setOnMenuItemClickListener(listener: OnMenuItemClickListener) {
binding.sessionsListHeaderMenu.setOnMenuItemClickListener(listener)
}
}

View file

@ -20,10 +20,17 @@ import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType
sealed class OtherSessionsAction : VectorViewModelAction {
// ReAuth
object SsoAuthDone : OtherSessionsAction()
data class PasswordAuthDone(val password: String) : OtherSessionsAction()
object ReAuthCancelled : OtherSessionsAction()
// Others
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()
object MultiSignout : OtherSessionsAction()
}

View file

@ -16,6 +16,7 @@
package im.vector.app.features.settings.devices.v2.othersessions
import android.app.Activity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
@ -32,6 +33,8 @@ import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.extensions.setTextColor
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
@ -39,13 +42,16 @@ 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.auth.ReAuthActivity
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.DeviceManagerFilterType
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.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase
import im.vector.app.features.themes.ThemeUtils
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.extensions.orFalse
import javax.inject.Inject
@ -65,6 +71,8 @@ class OtherSessionsFragment :
@Inject lateinit var viewNavigator: OtherSessionsViewNavigator
@Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentOtherSessionsBinding {
return FragmentOtherSessionsBinding.inflate(layoutInflater, container, false)
}
@ -77,9 +85,33 @@ class OtherSessionsFragment :
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()
updateMultiSignoutMenuItem(menu, state)
}
}
private fun updateMultiSignoutMenuItem(menu: Menu, viewState: OtherSessionsViewState) {
val multiSignoutItem = menu.findItem(R.id.otherSessionsMultiSignout)
multiSignoutItem.title = if (viewState.isSelectModeEnabled) {
getString(R.string.device_manager_other_sessions_multi_signout_selection).uppercase()
} else {
val nbDevices = viewState.devices()?.size ?: 0
stringProvider.getQuantityString(R.plurals.device_manager_other_sessions_multi_signout_all, nbDevices, nbDevices)
}
multiSignoutItem.isVisible = if (viewState.isSelectModeEnabled) {
viewState.devices.invoke()?.any { it.isSelected }.orFalse()
} else {
viewState.devices.invoke()?.isNotEmpty().orFalse()
}
val showAsActionFlag = if (viewState.isSelectModeEnabled) MenuItem.SHOW_AS_ACTION_IF_ROOM else MenuItem.SHOW_AS_ACTION_NEVER
multiSignoutItem.setShowAsAction(showAsActionFlag or MenuItem.SHOW_AS_ACTION_WITH_TEXT)
changeTextColorOfDestructiveAction(multiSignoutItem)
}
private fun changeTextColorOfDestructiveAction(menuItem: MenuItem) {
val titleColor = colorProvider.getColorFromAttribute(R.attr.colorError)
menuItem.setTextColor(titleColor)
}
override fun handleMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.otherSessionsSelect -> {
@ -94,10 +126,25 @@ class OtherSessionsFragment :
viewModel.handle(OtherSessionsAction.DeselectAll)
true
}
R.id.otherSessionsMultiSignout -> {
confirmMultiSignout()
true
}
else -> false
}
}
private fun confirmMultiSignout() {
activity?.let {
buildConfirmSignoutDialogUseCase.execute(it, this::multiSignout)
.show()
}
}
private fun multiSignout() {
viewModel.handle(OtherSessionsAction.MultiSignout)
}
private fun enableSelectMode(isEnabled: Boolean, deviceId: String? = null) {
val action = if (isEnabled) OtherSessionsAction.EnableSelectMode(deviceId) else OtherSessionsAction.DisableSelectMode
viewModel.handle(action)
@ -129,8 +176,9 @@ class OtherSessionsFragment :
private fun observeViewEvents() {
viewModel.observeViewEvents {
when (it) {
is OtherSessionsViewEvents.Loading -> showLoading(it.message)
is OtherSessionsViewEvents.Failure -> showFailure(it.throwable)
is OtherSessionsViewEvents.SignoutError -> showFailure(it.error)
is OtherSessionsViewEvents.RequestReAuth -> askForReAuthentication(it)
OtherSessionsViewEvents.SignoutSuccess -> enableSelectMode(false)
}
}
}
@ -162,6 +210,7 @@ class OtherSessionsFragment :
}
override fun invalidate() = withState(viewModel) { state ->
updateLoading(state.isLoading)
if (state.devices is Success) {
val devices = state.devices.invoke()
renderDevices(devices, state.currentFilter)
@ -169,6 +218,14 @@ class OtherSessionsFragment :
}
}
private fun updateLoading(isLoading: Boolean) {
if (isLoading) {
showLoading(null)
} else {
dismissLoadingDialog()
}
}
private fun updateToolbar(devices: List<DeviceFullInfo>, isSelectModeEnabled: Boolean) {
invalidateOptionsMenu()
val title = if (isSelectModeEnabled) {
@ -286,4 +343,37 @@ class OtherSessionsFragment :
override fun onViewAllOtherSessionsClicked() {
// NOOP. We don't have this button in this screen
}
private val reAuthActivityResultLauncher = registerStartForActivityResult { activityResult ->
if (activityResult.resultCode == Activity.RESULT_OK) {
when (activityResult.data?.extras?.getString(ReAuthActivity.RESULT_FLOW_TYPE)) {
LoginFlowTypes.SSO -> {
viewModel.handle(OtherSessionsAction.SsoAuthDone)
}
LoginFlowTypes.PASSWORD -> {
val password = activityResult.data?.extras?.getString(ReAuthActivity.RESULT_VALUE) ?: ""
viewModel.handle(OtherSessionsAction.PasswordAuthDone(password))
}
else -> {
viewModel.handle(OtherSessionsAction.ReAuthCancelled)
}
}
} else {
viewModel.handle(OtherSessionsAction.ReAuthCancelled)
}
}
/**
* Launch the re auth activity to get credentials.
*/
private fun askForReAuthentication(reAuthReq: OtherSessionsViewEvents.RequestReAuth) {
ReAuthActivity.newIntent(
requireContext(),
reAuthReq.registrationFlowResponse,
reAuthReq.lastErrorCode,
getString(R.string.devices_delete_dialog_title)
).let { intent ->
reAuthActivityResultLauncher.launch(intent)
}
}
}

View file

@ -17,8 +17,14 @@
package im.vector.app.features.settings.devices.v2.othersessions
import im.vector.app.core.platform.VectorViewEvents
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
sealed class OtherSessionsViewEvents : VectorViewEvents {
data class Loading(val message: CharSequence? = null) : OtherSessionsViewEvents()
data class Failure(val throwable: Throwable) : OtherSessionsViewEvents()
data class RequestReAuth(
val registrationFlowResponse: RegistrationFlowResponse,
val lastErrorCode: String?
) : OtherSessionsViewEvents()
object SignoutSuccess : OtherSessionsViewEvents()
data class SignoutError(val error: Throwable) : OtherSessionsViewEvents()
}

View file

@ -24,16 +24,24 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.features.auth.PendingAuthHandler
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.VectorSessionsListViewModel
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
import timber.log.Timber
class OtherSessionsViewModel @AssistedInject constructor(
@Assisted private val initialState: OtherSessionsViewState,
activeSessionHolder: ActiveSessionHolder,
private val getDeviceFullInfoListUseCase: GetDeviceFullInfoListUseCase,
private val signoutSessionsUseCase: SignoutSessionsUseCase,
private val pendingAuthHandler: PendingAuthHandler,
refreshDevicesUseCase: RefreshDevicesUseCase
) : VectorSessionsListViewModel<OtherSessionsViewState, OtherSessionsAction, OtherSessionsViewEvents>(
initialState, activeSessionHolder, refreshDevicesUseCase
@ -67,12 +75,16 @@ class OtherSessionsViewModel @AssistedInject constructor(
override fun handle(action: OtherSessionsAction) {
when (action) {
is OtherSessionsAction.PasswordAuthDone -> handlePasswordAuthDone(action)
OtherSessionsAction.ReAuthCancelled -> handleReAuthCancelled()
OtherSessionsAction.SsoAuthDone -> handleSsoAuthDone()
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()
OtherSessionsAction.MultiSignout -> handleMultiSignout()
}
}
@ -142,4 +154,67 @@ class OtherSessionsViewModel @AssistedInject constructor(
)
}
}
private fun handleMultiSignout() = withState { state ->
viewModelScope.launch {
setLoading(true)
val deviceIds = getDeviceIdsToSignout(state)
if (deviceIds.isEmpty()) {
return@launch
}
val result = signout(deviceIds)
setLoading(false)
val error = result.exceptionOrNull()
if (error == null) {
onSignoutSuccess()
} else {
onSignoutFailure(error)
}
}
}
private fun getDeviceIdsToSignout(state: OtherSessionsViewState): List<String> {
return if (state.isSelectModeEnabled) {
state.devices()?.filter { it.isSelected }.orEmpty()
} else {
state.devices().orEmpty()
}.mapNotNull { it.deviceInfo.deviceId }
}
private suspend fun signout(deviceIds: List<String>) = signoutSessionsUseCase.execute(deviceIds, this::onReAuthNeeded)
private fun onReAuthNeeded(reAuthNeeded: SignoutSessionsReAuthNeeded) {
Timber.d("onReAuthNeeded")
pendingAuthHandler.pendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session)
pendingAuthHandler.uiaContinuation = reAuthNeeded.uiaContinuation
_viewEvents.post(OtherSessionsViewEvents.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode))
}
private fun setLoading(isLoading: Boolean) {
setState { copy(isLoading = isLoading) }
}
private fun onSignoutSuccess() {
Timber.d("signout success")
refreshDeviceList()
_viewEvents.post(OtherSessionsViewEvents.SignoutSuccess)
}
private fun onSignoutFailure(failure: Throwable) {
Timber.e("signout failure", failure)
_viewEvents.post(OtherSessionsViewEvents.SignoutError(failure))
}
private fun handleSsoAuthDone() {
pendingAuthHandler.ssoAuthDone()
}
private fun handlePasswordAuthDone(action: OtherSessionsAction.PasswordAuthDone) {
pendingAuthHandler.passwordAuthDone(action.password)
}
private fun handleReAuthCancelled() {
pendingAuthHandler.reAuthCancelled()
}
}

View file

@ -27,6 +27,7 @@ data class OtherSessionsViewState(
val currentFilter: DeviceManagerFilterType = DeviceManagerFilterType.ALL_SESSIONS,
val excludeCurrentDevice: Boolean = false,
val isSelectModeEnabled: Boolean = false,
val isLoading: Boolean = false,
) : MavericksState {
constructor(args: OtherSessionsArgs) : this(excludeCurrentDevice = args.excludeCurrentDevice)

View file

@ -29,7 +29,6 @@ import androidx.core.view.isVisible
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.date.VectorDateFormatter
@ -45,6 +44,7 @@ import im.vector.app.features.crypto.recover.SetupMode
import im.vector.app.features.settings.devices.v2.list.SessionInfoViewState
import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet
import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus
import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase
import im.vector.app.features.workers.signout.SignOutUiWorker
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.extensions.orFalse
@ -69,6 +69,8 @@ class SessionOverviewFragment :
@Inject lateinit var stringProvider: StringProvider
@Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase
private val viewModel: SessionOverviewViewModel by fragmentViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSessionOverviewBinding {
@ -134,13 +136,7 @@ class SessionOverviewFragment :
private fun confirmSignoutOtherSession() {
activity?.let {
MaterialAlertDialogBuilder(it)
.setTitle(R.string.action_sign_out)
.setMessage(R.string.action_sign_out_confirmation_simple)
.setPositiveButton(R.string.action_sign_out) { _, _ ->
signoutSession()
}
.setNegativeButton(R.string.action_cancel, null)
buildConfirmSignoutDialogUseCase.execute(it, this::signoutSession)
.show()
}
}

View file

@ -21,42 +21,33 @@ import com.airbnb.mvrx.Success
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.auth.PendingAuthHandler
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase
import im.vector.app.features.settings.devices.v2.VectorSessionsListViewModel
import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase
import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase
import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionResult
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionUseCase
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase
import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
import timber.log.Timber
import javax.net.ssl.HttpsURLConnection
import kotlin.coroutines.Continuation
class SessionOverviewViewModel @AssistedInject constructor(
@Assisted val initialState: SessionOverviewViewState,
private val stringProvider: StringProvider,
private val getDeviceFullInfoUseCase: GetDeviceFullInfoUseCase,
private val checkIfCurrentSessionCanBeVerifiedUseCase: CheckIfCurrentSessionCanBeVerifiedUseCase,
private val signoutSessionUseCase: SignoutSessionUseCase,
private val signoutSessionsUseCase: SignoutSessionsUseCase,
private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase,
private val pendingAuthHandler: PendingAuthHandler,
private val activeSessionHolder: ActiveSessionHolder,
@ -154,30 +145,21 @@ class SessionOverviewViewModel @AssistedInject constructor(
private fun handleSignoutOtherSession(deviceId: String) {
viewModelScope.launch {
setLoading(true)
val signoutResult = signout(deviceId)
val result = signout(deviceId)
setLoading(false)
if (signoutResult.isSuccess) {
val error = result.exceptionOrNull()
if (error == null) {
onSignoutSuccess()
} else {
when (val failure = signoutResult.exceptionOrNull()) {
null -> onSignoutSuccess()
else -> onSignoutFailure(failure)
}
onSignoutFailure(error)
}
}
}
private suspend fun signout(deviceId: String) = signoutSessionUseCase.execute(deviceId, object : UserInteractiveAuthInterceptor {
override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation<UIABaseAuth>) {
when (val result = interceptSignoutFlowResponseUseCase.execute(flowResponse, errCode, promise)) {
is SignoutSessionResult.ReAuthNeeded -> onReAuthNeeded(result)
is SignoutSessionResult.Completed -> Unit
}
}
})
private suspend fun signout(deviceId: String) = signoutSessionsUseCase.execute(listOf(deviceId), this::onReAuthNeeded)
private fun onReAuthNeeded(reAuthNeeded: SignoutSessionResult.ReAuthNeeded) {
private fun onReAuthNeeded(reAuthNeeded: SignoutSessionsReAuthNeeded) {
Timber.d("onReAuthNeeded")
pendingAuthHandler.pendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session)
pendingAuthHandler.uiaContinuation = reAuthNeeded.uiaContinuation
@ -196,12 +178,7 @@ class SessionOverviewViewModel @AssistedInject constructor(
private fun onSignoutFailure(failure: Throwable) {
Timber.e("signout failure", failure)
val failureMessage = if (failure is Failure.OtherServerError && failure.httpCode == HttpsURLConnection.HTTP_UNAUTHORIZED) {
stringProvider.getString(R.string.authentication_error)
} else {
stringProvider.getString(R.string.matrix_error)
}
_viewEvents.post(SessionOverviewViewEvent.SignoutError(Exception(failureMessage)))
_viewEvents.post(SessionOverviewViewEvent.SignoutError(failure))
}
private fun handleSsoAuthDone() {

View file

@ -0,0 +1,35 @@
/*
* 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.signout
import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import im.vector.app.R
import javax.inject.Inject
class BuildConfirmSignoutDialogUseCase @Inject constructor() {
fun execute(context: Context, onConfirm: () -> Unit) =
MaterialAlertDialogBuilder(context)
.setTitle(R.string.action_sign_out)
.setMessage(R.string.action_sign_out_confirmation_simple)
.setPositiveButton(R.string.action_sign_out) { _, _ ->
onConfirm()
}
.setNegativeButton(R.string.action_cancel, null)
.create()
}

View file

@ -37,17 +37,16 @@ class InterceptSignoutFlowResponseUseCase @Inject constructor(
flowResponse: RegistrationFlowResponse,
errCode: String?,
promise: Continuation<UIABaseAuth>
): SignoutSessionResult {
): SignoutSessionsReAuthNeeded? {
return if (flowResponse.nextUncompletedStage() == LoginFlowTypes.PASSWORD && reAuthHelper.data != null && errCode == null) {
UserPasswordAuth(
session = null,
user = activeSessionHolder.getActiveSession().myUserId,
password = reAuthHelper.data
).let { promise.resume(it) }
SignoutSessionResult.Completed
null
} else {
SignoutSessionResult.ReAuthNeeded(
SignoutSessionsReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = flowResponse.session),
uiaContinuation = promise,
flowResponse = flowResponse,

View file

@ -1,39 +0,0 @@
/*
* 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.signout
import im.vector.app.core.di.ActiveSessionHolder
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.util.awaitCallback
import javax.inject.Inject
class SignoutSessionUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
) {
suspend fun execute(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor): Result<Unit> {
return deleteDevice(deviceId, userInteractiveAuthInterceptor)
}
private suspend fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor) = runCatching {
awaitCallback { matrixCallback ->
activeSessionHolder.getActiveSession()
.cryptoService()
.deleteDevice(deviceId, userInteractiveAuthInterceptor, matrixCallback)
}
}
}

View file

@ -20,13 +20,9 @@ import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import kotlin.coroutines.Continuation
sealed class SignoutSessionResult {
data class ReAuthNeeded(
val pendingAuth: UIABaseAuth,
val uiaContinuation: Continuation<UIABaseAuth>,
val flowResponse: RegistrationFlowResponse,
val errCode: String?
) : SignoutSessionResult()
object Completed : SignoutSessionResult()
}
data class SignoutSessionsReAuthNeeded(
val pendingAuth: UIABaseAuth,
val uiaContinuation: Continuation<UIABaseAuth>,
val flowResponse: RegistrationFlowResponse,
val errCode: String?
)

View file

@ -0,0 +1,57 @@
/*
* 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.signout
import androidx.annotation.Size
import im.vector.app.core.di.ActiveSessionHolder
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.util.awaitCallback
import timber.log.Timber
import javax.inject.Inject
import kotlin.coroutines.Continuation
class SignoutSessionsUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase,
) {
suspend fun execute(
@Size(min = 1) deviceIds: List<String>,
onReAuthNeeded: (SignoutSessionsReAuthNeeded) -> Unit,
): Result<Unit> = runCatching {
Timber.d("start execute with ${deviceIds.size} deviceIds")
val authInterceptor = object : UserInteractiveAuthInterceptor {
override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation<UIABaseAuth>) {
val result = interceptSignoutFlowResponseUseCase.execute(flowResponse, errCode, promise)
result?.let(onReAuthNeeded)
}
}
deleteDevices(deviceIds, authInterceptor)
Timber.d("end execute")
}
private suspend fun deleteDevices(deviceIds: List<String>, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor) =
awaitCallback { matrixCallback ->
activeSessionHolder.getActiveSession()
.cryptoService()
.deleteDevices(deviceIds, userInteractiveAuthInterceptor, matrixCallback)
}
}

View file

@ -98,6 +98,7 @@
app:layout_constraintTop_toBottomOf="@id/deviceListDividerCurrentSession"
app:sessionsListHeaderDescription="@string/device_manager_sessions_other_description"
app:sessionsListHeaderHasLearnMoreLink="false"
app:sessionsListHeaderMenu="@menu/menu_other_sessions_header"
app:sessionsListHeaderTitle="@string/device_manager_sessions_other_title" />
<im.vector.app.features.settings.devices.v2.list.OtherSessionsView
@ -117,8 +118,8 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/deviceListOtherSessions"
app:sessionsListHeaderHasLearnMoreLink="false"
app:sessionsListHeaderDescription="@string/device_manager_sessions_sign_in_with_qr_code_description"
app:sessionsListHeaderHasLearnMoreLink="false"
app:sessionsListHeaderTitle="@string/device_manager_sessions_sign_in_with_qr_code_title"
tools:visibility="visible" />

View file

@ -13,7 +13,7 @@
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/layout_horizontal_margin"
android:layout_marginTop="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@id/sessionsListHeaderMenu"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Other sessions" />
@ -29,4 +29,13 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/sessions_list_header_title"
tools:text="For best security, verify your sessions and sign out from any session that you dont recognize or use anymore. Learn More." />
<androidx.appcompat.widget.ActionMenuView
android:id="@+id/sessionsListHeaderMenu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
app:layout_constraintBottom_toBottomOf="@id/sessions_list_header_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/sessions_list_header_title" />
</merge>

View file

@ -9,6 +9,11 @@
android:title="@string/device_manager_other_sessions_select"
app:showAsAction="withText|never" />
<item
android:id="@+id/otherSessionsMultiSignout"
android:title="@plurals/device_manager_other_sessions_multi_signout_all"
app:showAsAction="withText|never" />
<item
android:id="@+id/otherSessionsSelectAll"
android:title="@string/action_select_all"

View file

@ -0,0 +1,12 @@
<?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/otherSessionsHeaderMultiSignout"
android:title="@plurals/device_manager_other_sessions_multi_signout_all"
app:showAsAction="withText|never" />
</menu>

View file

@ -22,30 +22,41 @@ import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.core.session.clientinfo.MatrixClientInfoContent
import im.vector.app.features.settings.devices.v2.details.extended.DeviceExtendedInfo
import im.vector.app.features.settings.devices.v2.list.DeviceType
import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase
import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import im.vector.app.features.settings.devices.v2.verification.CurrentSessionCrossSigningInfo
import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakePendingAuthHandler
import im.vector.app.test.fakes.FakeSignoutSessionsUseCase
import im.vector.app.test.fakes.FakeVerificationService
import im.vector.app.test.test
import im.vector.app.test.testDispatcher
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkAll
import io.mockk.verify
import io.mockk.verifyAll
import kotlinx.coroutines.flow.flowOf
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
private const val A_CURRENT_DEVICE_ID = "current-device-id"
private const val A_DEVICE_ID_1 = "device-id-1"
private const val A_DEVICE_ID_2 = "device-id-2"
private const val A_PASSWORD = "password"
class DevicesViewModelTest {
@ -55,19 +66,25 @@ class DevicesViewModelTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val getCurrentSessionCrossSigningInfoUseCase = mockk<GetCurrentSessionCrossSigningInfoUseCase>()
private val getDeviceFullInfoListUseCase = mockk<GetDeviceFullInfoListUseCase>()
private val refreshDevicesUseCase = mockk<RefreshDevicesUseCase>(relaxUnitFun = true)
private val refreshDevicesOnCryptoDevicesChangeUseCase = mockk<RefreshDevicesOnCryptoDevicesChangeUseCase>()
private val refreshDevicesOnCryptoDevicesChangeUseCase = mockk<RefreshDevicesOnCryptoDevicesChangeUseCase>(relaxed = true)
private val checkIfCurrentSessionCanBeVerifiedUseCase = mockk<CheckIfCurrentSessionCanBeVerifiedUseCase>()
private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase()
private val fakeInterceptSignoutFlowResponseUseCase = mockk<InterceptSignoutFlowResponseUseCase>()
private val fakePendingAuthHandler = FakePendingAuthHandler()
private val fakeRefreshDevicesUseCase = mockk<RefreshDevicesUseCase>(relaxUnitFun = true)
private fun createViewModel(): DevicesViewModel {
return DevicesViewModel(
DevicesViewState(),
fakeActiveSessionHolder.instance,
getCurrentSessionCrossSigningInfoUseCase,
getDeviceFullInfoListUseCase,
refreshDevicesOnCryptoDevicesChangeUseCase,
checkIfCurrentSessionCanBeVerifiedUseCase,
refreshDevicesUseCase,
initialState = DevicesViewState(),
activeSessionHolder = fakeActiveSessionHolder.instance,
getCurrentSessionCrossSigningInfoUseCase = getCurrentSessionCrossSigningInfoUseCase,
getDeviceFullInfoListUseCase = getDeviceFullInfoListUseCase,
refreshDevicesOnCryptoDevicesChangeUseCase = refreshDevicesOnCryptoDevicesChangeUseCase,
checkIfCurrentSessionCanBeVerifiedUseCase = checkIfCurrentSessionCanBeVerifiedUseCase,
signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance,
interceptSignoutFlowResponseUseCase = fakeInterceptSignoutFlowResponseUseCase,
pendingAuthHandler = fakePendingAuthHandler.instance,
refreshDevicesUseCase = fakeRefreshDevicesUseCase,
)
}
@ -76,6 +93,20 @@ class DevicesViewModelTest {
// Needed for internal usage of Flow<T>.throttleFirst() inside the ViewModel
mockkStatic(SystemClock::class)
every { SystemClock.elapsedRealtime() } returns 1234
givenVerificationService()
givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2)
}
private fun givenVerificationService(): FakeVerificationService {
val fakeVerificationService = fakeActiveSessionHolder
.fakeSession
.fakeCryptoService
.fakeVerificationService
fakeVerificationService.givenAddListenerSucceeds()
fakeVerificationService.givenRemoveListenerSucceeds()
return fakeVerificationService
}
@After
@ -87,9 +118,6 @@ class DevicesViewModelTest {
fun `given the viewModel when initializing it then verification listener is added`() {
// Given
val fakeVerificationService = givenVerificationService()
givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
// When
val viewModel = createViewModel()
@ -104,9 +132,6 @@ class DevicesViewModelTest {
fun `given the viewModel when clearing it then verification listener is removed`() {
// Given
val fakeVerificationService = givenVerificationService()
givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
// When
val viewModel = createViewModel()
@ -121,10 +146,7 @@ class DevicesViewModelTest {
@Test
fun `given the viewModel when initializing it then view state is updated with current session cross signing info`() {
// Given
givenVerificationService()
val currentSessionCrossSigningInfo = givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
// When
val viewModelTest = createViewModel().test()
@ -137,10 +159,7 @@ class DevicesViewModelTest {
@Test
fun `given the viewModel when initializing it then view state is updated with current device full info list`() {
// Given
givenVerificationService()
givenCurrentSessionCrossSigningInfo()
val deviceFullInfoList = givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
val deviceFullInfoList = givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2)
// When
val viewModelTest = createViewModel().test()
@ -156,10 +175,6 @@ class DevicesViewModelTest {
@Test
fun `given the viewModel when initializing it then devices are refreshed on crypto devices change`() {
// Given
givenVerificationService()
givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
// When
createViewModel()
@ -171,10 +186,6 @@ class DevicesViewModelTest {
@Test
fun `given current session can be verified when handling verify current session action then self verification event is posted`() {
// Given
givenVerificationService()
givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
val verifyCurrentSessionAction = DevicesAction.VerifyCurrentSession
coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns true
@ -195,10 +206,6 @@ class DevicesViewModelTest {
@Test
fun `given current session cannot be verified when handling verify current session action then reset secrets event is posted`() {
// Given
givenVerificationService()
givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
val verifyCurrentSessionAction = DevicesAction.VerifyCurrentSession
coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns false
@ -216,18 +223,129 @@ class DevicesViewModelTest {
}
}
private fun givenVerificationService(): FakeVerificationService {
val fakeVerificationService = fakeActiveSessionHolder
.fakeSession
.fakeCryptoService
.fakeVerificationService
fakeVerificationService.givenAddListenerSucceeds()
fakeVerificationService.givenRemoveListenerSucceeds()
return fakeVerificationService
@Test
fun `given no reAuth is needed when handling multiSignout other sessions action then signout process is performed`() {
// Given
val expectedViewState = givenInitialViewState(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_CURRENT_DEVICE_ID)
// signout all devices except the current device
fakeSignoutSessionsUseCase.givenSignoutSuccess(listOf(A_DEVICE_ID_1))
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(DevicesAction.MultiSignoutOtherSessions)
// Then
viewModelTest
.assertStatesChanges(
expectedViewState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is DevicesViewEvent.SignoutSuccess }
.finish()
verify {
fakeRefreshDevicesUseCase.execute()
}
}
@Test
fun `given unexpected error during multiSignout when handling multiSignout action then signout process is performed`() {
// Given
val error = Exception()
fakeSignoutSessionsUseCase.givenSignoutError(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2), error)
val expectedViewState = givenInitialViewState(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(DevicesAction.MultiSignoutOtherSessions)
// Then
viewModelTest
.assertStatesChanges(
expectedViewState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is DevicesViewEvent.SignoutError && it.error == error }
.finish()
}
@Test
fun `given reAuth is needed during multiSignout when handling multiSignout action then requestReAuth is sent and pending auth is stored`() {
// Given
val reAuthNeeded = fakeSignoutSessionsUseCase.givenSignoutReAuthNeeded(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2))
val expectedPendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session)
val expectedReAuthEvent = DevicesViewEvent.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(DevicesAction.MultiSignoutOtherSessions)
// Then
viewModelTest
.assertEvent { it == expectedReAuthEvent }
.finish()
fakePendingAuthHandler.instance.pendingAuth shouldBeEqualTo expectedPendingAuth
fakePendingAuthHandler.instance.uiaContinuation shouldBeEqualTo reAuthNeeded.uiaContinuation
}
@Test
fun `given SSO auth has been done when handling ssoAuthDone action then corresponding method of pending auth handler is called`() {
// Given
justRun { fakePendingAuthHandler.instance.ssoAuthDone() }
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(DevicesAction.SsoAuthDone)
// Then
viewModelTest.finish()
verifyAll {
fakePendingAuthHandler.instance.ssoAuthDone()
}
}
@Test
fun `given password auth has been done when handling passwordAuthDone action then corresponding method of pending auth handler is called`() {
// Given
justRun { fakePendingAuthHandler.instance.passwordAuthDone(any()) }
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(DevicesAction.PasswordAuthDone(A_PASSWORD))
// Then
viewModelTest.finish()
verifyAll {
fakePendingAuthHandler.instance.passwordAuthDone(A_PASSWORD)
}
}
@Test
fun `given reAuth has been cancelled when handling reAuthCancelled action then corresponding method of pending auth handler is called`() {
// Given
justRun { fakePendingAuthHandler.instance.reAuthCancelled() }
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(DevicesAction.ReAuthCancelled)
// Then
viewModelTest.finish()
verifyAll {
fakePendingAuthHandler.instance.reAuthCancelled()
}
}
private fun givenCurrentSessionCrossSigningInfo(): CurrentSessionCrossSigningInfo {
val currentSessionCrossSigningInfo = mockk<CurrentSessionCrossSigningInfo>()
every { currentSessionCrossSigningInfo.deviceId } returns A_CURRENT_DEVICE_ID
every { getCurrentSessionCrossSigningInfoUseCase.execute() } returns flowOf(currentSessionCrossSigningInfo)
return currentSessionCrossSigningInfo
}
@ -235,14 +353,19 @@ class DevicesViewModelTest {
/**
* Generate mocked deviceFullInfo list with 1 unverified and inactive + 1 verified and active.
*/
private fun givenDeviceFullInfoList(): List<DeviceFullInfo> {
private fun givenDeviceFullInfoList(deviceId1: String, deviceId2: String): List<DeviceFullInfo> {
val verifiedCryptoDeviceInfo = mockk<CryptoDeviceInfo>()
every { verifiedCryptoDeviceInfo.trustLevel } returns DeviceTrustLevel(crossSigningVerified = true, locallyVerified = true)
val unverifiedCryptoDeviceInfo = mockk<CryptoDeviceInfo>()
every { unverifiedCryptoDeviceInfo.trustLevel } returns DeviceTrustLevel(crossSigningVerified = false, locallyVerified = false)
val deviceInfo1 = mockk<DeviceInfo>()
every { deviceInfo1.deviceId } returns deviceId1
val deviceInfo2 = mockk<DeviceInfo>()
every { deviceInfo2.deviceId } returns deviceId2
val deviceFullInfo1 = DeviceFullInfo(
deviceInfo = mockk(),
deviceInfo = deviceInfo1,
cryptoDeviceInfo = verifiedCryptoDeviceInfo,
roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted,
isInactive = false,
@ -251,7 +374,7 @@ class DevicesViewModelTest {
matrixClientInfo = MatrixClientInfoContent(),
)
val deviceFullInfo2 = DeviceFullInfo(
deviceInfo = mockk(),
deviceInfo = deviceInfo2,
cryptoDeviceInfo = unverifiedCryptoDeviceInfo,
roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Warning,
isInactive = true,
@ -265,7 +388,15 @@ class DevicesViewModelTest {
return deviceFullInfoList
}
private fun givenRefreshDevicesOnCryptoDevicesChange() {
coEvery { refreshDevicesOnCryptoDevicesChangeUseCase.execute() } just runs
private fun givenInitialViewState(deviceId1: String, deviceId2: String): DevicesViewState {
val currentSessionCrossSigningInfo = givenCurrentSessionCrossSigningInfo()
val deviceFullInfoList = givenDeviceFullInfoList(deviceId1, deviceId2)
return DevicesViewState(
currentSessionCrossSigningInfo = currentSessionCrossSigningInfo,
devices = Success(deviceFullInfoList),
unverifiedSessionsCount = 1,
inactiveSessionsCount = 1,
isLoading = false,
)
}
}

View file

@ -24,23 +24,31 @@ 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.FakePendingAuthHandler
import im.vector.app.test.fakes.FakeSignoutSessionsUseCase
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.justRun
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import io.mockk.verifyAll
import kotlinx.coroutines.flow.flowOf
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
private const val A_TITLE_RES_ID = 1
private const val A_DEVICE_ID = "device-id"
private const val A_DEVICE_ID_1 = "device-id-1"
private const val A_DEVICE_ID_2 = "device-id-2"
private const val A_PASSWORD = "password"
class OtherSessionsViewModelTest {
@ -55,14 +63,19 @@ class OtherSessionsViewModelTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val fakeGetDeviceFullInfoListUseCase = mockk<GetDeviceFullInfoListUseCase>()
private val fakeRefreshDevicesUseCaseUseCase = mockk<RefreshDevicesUseCase>()
private val fakeRefreshDevicesUseCase = mockk<RefreshDevicesUseCase>(relaxed = true)
private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase()
private val fakePendingAuthHandler = FakePendingAuthHandler()
private fun createViewModel(args: OtherSessionsArgs = defaultArgs) = OtherSessionsViewModel(
initialState = OtherSessionsViewState(args),
activeSessionHolder = fakeActiveSessionHolder.instance,
getDeviceFullInfoListUseCase = fakeGetDeviceFullInfoListUseCase,
refreshDevicesUseCase = fakeRefreshDevicesUseCaseUseCase,
)
private fun createViewModel(viewState: OtherSessionsViewState = OtherSessionsViewState(defaultArgs)) =
OtherSessionsViewModel(
initialState = viewState,
activeSessionHolder = fakeActiveSessionHolder.instance,
getDeviceFullInfoListUseCase = fakeGetDeviceFullInfoListUseCase,
signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance,
pendingAuthHandler = fakePendingAuthHandler.instance,
refreshDevicesUseCase = fakeRefreshDevicesUseCase,
)
@Before
fun setup() {
@ -88,6 +101,39 @@ class OtherSessionsViewModelTest {
unmockkAll()
}
@Test
fun `given the viewModel when initializing it then verification listener is added`() {
// Given
val fakeVerificationService = givenVerificationService()
val devices = mockk<List<DeviceFullInfo>>()
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
// When
val viewModel = createViewModel()
// Then
verify {
fakeVerificationService.addListener(viewModel)
}
}
@Test
fun `given the viewModel when clearing it then verification listener is removed`() {
// Given
val fakeVerificationService = givenVerificationService()
val devices = mockk<List<DeviceFullInfo>>()
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
// When
val viewModel = createViewModel()
viewModel.onCleared()
// Then
verify {
fakeVerificationService.removeListener(viewModel)
}
}
@Test
fun `given the viewModel has been initialized then viewState is updated with devices list`() {
// Given
@ -143,7 +189,7 @@ class OtherSessionsViewModelTest {
@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 deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState(
@ -156,7 +202,7 @@ class OtherSessionsViewModelTest {
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(OtherSessionsAction.EnableSelectMode(A_DEVICE_ID))
viewModel.handle(OtherSessionsAction.EnableSelectMode(A_DEVICE_ID_1))
// Then
viewModelTest
@ -167,8 +213,8 @@ class OtherSessionsViewModelTest {
@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 deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = true)
val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = true)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState(
@ -192,7 +238,7 @@ class OtherSessionsViewModelTest {
@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 deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState(
@ -205,7 +251,7 @@ class OtherSessionsViewModelTest {
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(OtherSessionsAction.ToggleSelectionForDevice(A_DEVICE_ID))
viewModel.handle(OtherSessionsAction.ToggleSelectionForDevice(A_DEVICE_ID_1))
// Then
viewModelTest
@ -216,8 +262,8 @@ class OtherSessionsViewModelTest {
@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 deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState(
@ -241,8 +287,8 @@ class OtherSessionsViewModelTest {
@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 deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState(
@ -263,6 +309,190 @@ class OtherSessionsViewModelTest {
.finish()
}
@Test
fun `given no reAuth is needed and in selectMode when handling multiSignout action then signout process is performed`() {
// Given
val isSelectModeEnabled = true
val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
// signout only selected devices
fakeSignoutSessionsUseCase.givenSignoutSuccess(listOf(A_DEVICE_ID_2))
val expectedViewState = OtherSessionsViewState(
devices = Success(listOf(deviceFullInfo1, deviceFullInfo2)),
currentFilter = defaultArgs.defaultFilter,
excludeCurrentDevice = defaultArgs.excludeCurrentDevice,
isSelectModeEnabled = isSelectModeEnabled,
)
// When
val viewModel = createViewModel(OtherSessionsViewState(defaultArgs).copy(isSelectModeEnabled = isSelectModeEnabled))
val viewModelTest = viewModel.test()
viewModel.handle(OtherSessionsAction.MultiSignout)
// Then
viewModelTest
.assertStatesChanges(
expectedViewState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is OtherSessionsViewEvents.SignoutSuccess }
.finish()
verify {
fakeRefreshDevicesUseCase.execute()
}
}
@Test
fun `given no reAuth is needed and NOT in selectMode when handling multiSignout action then signout process is performed`() {
// Given
val isSelectModeEnabled = false
val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
// signout all devices
fakeSignoutSessionsUseCase.givenSignoutSuccess(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2))
val expectedViewState = OtherSessionsViewState(
devices = Success(listOf(deviceFullInfo1, deviceFullInfo2)),
currentFilter = defaultArgs.defaultFilter,
excludeCurrentDevice = defaultArgs.excludeCurrentDevice,
isSelectModeEnabled = isSelectModeEnabled,
)
// When
val viewModel = createViewModel(OtherSessionsViewState(defaultArgs).copy(isSelectModeEnabled = isSelectModeEnabled))
val viewModelTest = viewModel.test()
viewModel.handle(OtherSessionsAction.MultiSignout)
// Then
viewModelTest
.assertStatesChanges(
expectedViewState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is OtherSessionsViewEvents.SignoutSuccess }
.finish()
verify {
fakeRefreshDevicesUseCase.execute()
}
}
@Test
fun `given unexpected error during multiSignout when handling multiSignout action then signout process is performed`() {
// Given
val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val error = Exception()
fakeSignoutSessionsUseCase.givenSignoutError(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2), error)
val expectedViewState = OtherSessionsViewState(
devices = Success(listOf(deviceFullInfo1, deviceFullInfo2)),
currentFilter = defaultArgs.defaultFilter,
excludeCurrentDevice = defaultArgs.excludeCurrentDevice,
)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(OtherSessionsAction.MultiSignout)
// Then
viewModelTest
.assertStatesChanges(
expectedViewState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is OtherSessionsViewEvents.SignoutError && it.error == error }
.finish()
}
@Test
fun `given reAuth is needed during multiSignout when handling multiSignout action then requestReAuth is sent and pending auth is stored`() {
// Given
val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val reAuthNeeded = fakeSignoutSessionsUseCase.givenSignoutReAuthNeeded(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2))
val expectedPendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session)
val expectedReAuthEvent = OtherSessionsViewEvents.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(OtherSessionsAction.MultiSignout)
// Then
viewModelTest
.assertEvent { it == expectedReAuthEvent }
.finish()
fakePendingAuthHandler.instance.pendingAuth shouldBeEqualTo expectedPendingAuth
fakePendingAuthHandler.instance.uiaContinuation shouldBeEqualTo reAuthNeeded.uiaContinuation
}
@Test
fun `given SSO auth has been done when handling ssoAuthDone action then corresponding method of pending auth handler is called`() {
// Given
val devices = mockk<List<DeviceFullInfo>>()
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
justRun { fakePendingAuthHandler.instance.ssoAuthDone() }
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(OtherSessionsAction.SsoAuthDone)
// Then
viewModelTest.finish()
verifyAll {
fakePendingAuthHandler.instance.ssoAuthDone()
}
}
@Test
fun `given password auth has been done when handling passwordAuthDone action then corresponding method of pending auth handler is called`() {
// Given
val devices = mockk<List<DeviceFullInfo>>()
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
justRun { fakePendingAuthHandler.instance.passwordAuthDone(any()) }
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(OtherSessionsAction.PasswordAuthDone(A_PASSWORD))
// Then
viewModelTest.finish()
verifyAll {
fakePendingAuthHandler.instance.passwordAuthDone(A_PASSWORD)
}
}
@Test
fun `given reAuth has been cancelled when handling reAuthCancelled action then corresponding method of pending auth handler is called`() {
// Given
val devices = mockk<List<DeviceFullInfo>>()
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
justRun { fakePendingAuthHandler.instance.reAuthCancelled() }
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(OtherSessionsAction.ReAuthCancelled)
// Then
viewModelTest.finish()
verifyAll {
fakePendingAuthHandler.instance.reAuthCancelled()
}
}
private fun givenGetDeviceFullInfoListReturns(
filterType: DeviceManagerFilterType,
devices: List<DeviceFullInfo>,

View file

@ -20,18 +20,15 @@ import android.os.SystemClock
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.R
import im.vector.app.features.settings.devices.v2.DeviceFullInfo
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase
import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase
import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus
import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionResult
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionUseCase
import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakePendingAuthHandler
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fakes.FakeSignoutSessionsUseCase
import im.vector.app.test.fakes.FakeTogglePushNotificationUseCase
import im.vector.app.test.fakes.FakeVerificationService
import im.vector.app.test.test
@ -43,7 +40,6 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import io.mockk.verifyAll
@ -53,19 +49,11 @@ import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
import javax.net.ssl.HttpsURLConnection
import kotlin.coroutines.Continuation
private const val A_SESSION_ID_1 = "session-id-1"
private const val A_SESSION_ID_2 = "session-id-2"
private const val AUTH_ERROR_MESSAGE = "auth-error-message"
private const val AN_ERROR_MESSAGE = "error-message"
private const val A_PASSWORD = "password"
class SessionOverviewViewModelTest {
@ -81,22 +69,20 @@ class SessionOverviewViewModelTest {
)
private val getDeviceFullInfoUseCase = mockk<GetDeviceFullInfoUseCase>(relaxed = true)
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val fakeStringProvider = FakeStringProvider()
private val checkIfCurrentSessionCanBeVerifiedUseCase = mockk<CheckIfCurrentSessionCanBeVerifiedUseCase>()
private val signoutSessionUseCase = mockk<SignoutSessionUseCase>()
private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase()
private val interceptSignoutFlowResponseUseCase = mockk<InterceptSignoutFlowResponseUseCase>()
private val fakePendingAuthHandler = FakePendingAuthHandler()
private val refreshDevicesUseCase = mockk<RefreshDevicesUseCase>()
private val refreshDevicesUseCase = mockk<RefreshDevicesUseCase>(relaxed = true)
private val togglePushNotificationUseCase = FakeTogglePushNotificationUseCase()
private val fakeGetNotificationsStatusUseCase = mockk<GetNotificationsStatusUseCase>()
private val notificationsStatus = NotificationsStatus.ENABLED
private fun createViewModel() = SessionOverviewViewModel(
initialState = SessionOverviewViewState(args),
stringProvider = fakeStringProvider.instance,
getDeviceFullInfoUseCase = getDeviceFullInfoUseCase,
checkIfCurrentSessionCanBeVerifiedUseCase = checkIfCurrentSessionCanBeVerifiedUseCase,
signoutSessionUseCase = signoutSessionUseCase,
signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance,
interceptSignoutFlowResponseUseCase = interceptSignoutFlowResponseUseCase,
pendingAuthHandler = fakePendingAuthHandler.instance,
activeSessionHolder = fakeActiveSessionHolder.instance,
@ -115,11 +101,50 @@ class SessionOverviewViewModelTest {
every { fakeGetNotificationsStatusUseCase.execute(A_SESSION_ID_1) } returns flowOf(notificationsStatus)
}
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 when initializing it then verification listener is added`() {
// Given
val fakeVerificationService = givenVerificationService()
// When
val viewModel = createViewModel()
// Then
verify {
fakeVerificationService.addListener(viewModel)
}
}
@Test
fun `given the viewModel when clearing it then verification listener is removed`() {
// Given
val fakeVerificationService = givenVerificationService()
// When
val viewModel = createViewModel()
viewModel.onCleared()
// Then
verify {
fakeVerificationService.removeListener(viewModel)
}
}
@Test
fun `given the viewModel has been initialized then pushers are refreshed`() {
createViewModel()
@ -223,8 +248,7 @@ class SessionOverviewViewModelTest {
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
givenSignoutSuccess(A_SESSION_ID_1)
every { refreshDevicesUseCase.execute() } just runs
fakeSignoutSessionsUseCase.givenSignoutSuccess(listOf(A_SESSION_ID_1))
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState(
@ -254,41 +278,6 @@ class SessionOverviewViewModelTest {
}
}
@Test
fun `given another session and server error during signout when handling signout action then signout process is performed`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val serverError = Failure.OtherServerError(errorBody = "", httpCode = HttpsURLConnection.HTTP_UNAUTHORIZED)
givenSignoutError(A_SESSION_ID_1, serverError)
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState(
deviceId = A_SESSION_ID_1,
isCurrentSessionTrusted = true,
deviceInfo = Success(deviceFullInfo),
isLoading = false,
notificationsStatus = notificationsStatus,
)
fakeStringProvider.given(R.string.authentication_error, AUTH_ERROR_MESSAGE)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(signoutAction)
// Then
viewModelTest
.assertStatesChanges(
expectedViewState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error.message == AUTH_ERROR_MESSAGE }
.finish()
}
@Test
fun `given another session and unexpected error during signout when handling signout action then signout process is performed`() {
// Given
@ -296,7 +285,7 @@ class SessionOverviewViewModelTest {
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val error = Exception()
givenSignoutError(A_SESSION_ID_1, error)
fakeSignoutSessionsUseCase.givenSignoutError(listOf(A_SESSION_ID_1), error)
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState(
@ -306,7 +295,6 @@ class SessionOverviewViewModelTest {
isLoading = false,
notificationsStatus = notificationsStatus,
)
fakeStringProvider.given(R.string.matrix_error, AN_ERROR_MESSAGE)
// When
val viewModel = createViewModel()
@ -320,7 +308,7 @@ class SessionOverviewViewModelTest {
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error.message == AN_ERROR_MESSAGE }
.assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error == error }
.finish()
}
@ -330,7 +318,7 @@ class SessionOverviewViewModelTest {
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val reAuthNeeded = givenSignoutReAuthNeeded(A_SESSION_ID_1)
val reAuthNeeded = fakeSignoutSessionsUseCase.givenSignoutReAuthNeeded(listOf(A_SESSION_ID_1))
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedPendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session)
@ -415,53 +403,6 @@ class SessionOverviewViewModelTest {
}
}
private fun givenSignoutSuccess(deviceId: String) {
val interceptor = slot<UserInteractiveAuthInterceptor>()
val flowResponse = mockk<RegistrationFlowResponse>()
val errorCode = "errorCode"
val promise = mockk<Continuation<UIABaseAuth>>()
every { interceptSignoutFlowResponseUseCase.execute(flowResponse, errorCode, promise) } returns SignoutSessionResult.Completed
coEvery { signoutSessionUseCase.execute(deviceId, capture(interceptor)) } coAnswers {
secondArg<UserInteractiveAuthInterceptor>().performStage(flowResponse, errorCode, promise)
Result.success(Unit)
}
}
private fun givenSignoutReAuthNeeded(deviceId: String): SignoutSessionResult.ReAuthNeeded {
val interceptor = slot<UserInteractiveAuthInterceptor>()
val flowResponse = mockk<RegistrationFlowResponse>()
every { flowResponse.session } returns A_SESSION_ID_1
val errorCode = "errorCode"
val promise = mockk<Continuation<UIABaseAuth>>()
val reAuthNeeded = SignoutSessionResult.ReAuthNeeded(
pendingAuth = mockk(),
uiaContinuation = promise,
flowResponse = flowResponse,
errCode = errorCode,
)
every { interceptSignoutFlowResponseUseCase.execute(flowResponse, errorCode, promise) } returns reAuthNeeded
coEvery { signoutSessionUseCase.execute(deviceId, capture(interceptor)) } coAnswers {
secondArg<UserInteractiveAuthInterceptor>().performStage(flowResponse, errorCode, promise)
Result.success(Unit)
}
return reAuthNeeded
}
private fun givenSignoutError(deviceId: String, error: Throwable) {
coEvery { signoutSessionUseCase.execute(deviceId, any()) } returns Result.failure(error)
}
private fun givenVerificationService(): FakeVerificationService {
val fakeVerificationService = fakeActiveSessionHolder
.fakeSession
.fakeCryptoService
.fakeVerificationService
fakeVerificationService.givenAddListenerSucceeds()
fakeVerificationService.givenRemoveListenerSucceeds()
return fakeVerificationService
}
private fun givenCurrentSessionIsTrusted() {
fakeActiveSessionHolder.fakeSession.givenSessionId(A_SESSION_ID_2)
val deviceFullInfo = mockk<DeviceFullInfo>()

View file

@ -24,8 +24,8 @@ import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkAll
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeInstanceOf
import org.junit.After
import org.junit.Before
import org.junit.Test
@ -63,7 +63,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
}
@Test
fun `given no error and a stored password and a next stage as password when intercepting then promise is resumed and success is returned`() {
fun `given no error and a stored password and a next stage as password when intercepting then promise is resumed and null is returned`() {
// Given
val registrationFlowResponse = givenNextUncompletedStage(LoginFlowTypes.PASSWORD, A_SESSION_ID)
fakeReAuthHelper.givenStoredPassword(A_PASSWORD)
@ -84,7 +84,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
)
// Then
result shouldBeInstanceOf (SignoutSessionResult.Completed::class)
result shouldBe null
every {
promise.resume(expectedAuth)
}
@ -97,7 +97,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
fakeReAuthHelper.givenStoredPassword(A_PASSWORD)
val errorCode = AN_ERROR_CODE
val promise = mockk<Continuation<UIABaseAuth>>()
val expectedResult = SignoutSessionResult.ReAuthNeeded(
val expectedResult = SignoutSessionsReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = A_SESSION_ID),
uiaContinuation = promise,
flowResponse = registrationFlowResponse,
@ -122,7 +122,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
fakeReAuthHelper.givenStoredPassword(A_PASSWORD)
val errorCode: String? = null
val promise = mockk<Continuation<UIABaseAuth>>()
val expectedResult = SignoutSessionResult.ReAuthNeeded(
val expectedResult = SignoutSessionsReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = A_SESSION_ID),
uiaContinuation = promise,
flowResponse = registrationFlowResponse,
@ -147,7 +147,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
fakeReAuthHelper.givenStoredPassword(null)
val errorCode: String? = null
val promise = mockk<Continuation<UIABaseAuth>>()
val expectedResult = SignoutSessionResult.ReAuthNeeded(
val expectedResult = SignoutSessionsReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = A_SESSION_ID),
uiaContinuation = promise,
flowResponse = registrationFlowResponse,

View file

@ -1,79 +0,0 @@
/*
* 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.signout
import im.vector.app.test.fakes.FakeActiveSessionHolder
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBe
import org.junit.Test
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
private const val A_DEVICE_ID = "device-id"
class SignoutSessionUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val signoutSessionUseCase = SignoutSessionUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance
)
@Test
fun `given a device id when signing out with success then success result is returned`() = runTest {
// Given
val interceptor = givenAuthInterceptor()
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.givenDeleteDeviceSucceeds(A_DEVICE_ID)
// When
val result = signoutSessionUseCase.execute(A_DEVICE_ID, interceptor)
// Then
result.isSuccess shouldBe true
every {
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.deleteDevice(A_DEVICE_ID, interceptor, any())
}
}
@Test
fun `given a device id when signing out with error then failure result is returned`() = runTest {
// Given
val interceptor = givenAuthInterceptor()
val error = mockk<Exception>()
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.givenDeleteDeviceFailsWithError(A_DEVICE_ID, error)
// When
val result = signoutSessionUseCase.execute(A_DEVICE_ID, interceptor)
// Then
result.isFailure shouldBe true
every {
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.deleteDevice(A_DEVICE_ID, interceptor, any())
}
}
private fun givenAuthInterceptor() = mockk<UserInteractiveAuthInterceptor>()
}

View file

@ -0,0 +1,113 @@
/*
* 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.signout
import im.vector.app.test.fakes.FakeActiveSessionHolder
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBe
import org.junit.Test
private const val A_DEVICE_ID_1 = "device-id-1"
private const val A_DEVICE_ID_2 = "device-id-2"
class SignoutSessionsUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val fakeInterceptSignoutFlowResponseUseCase = mockk<InterceptSignoutFlowResponseUseCase>()
private val signoutSessionsUseCase = SignoutSessionsUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance,
interceptSignoutFlowResponseUseCase = fakeInterceptSignoutFlowResponseUseCase,
)
@Test
fun `given a list of device ids when signing out with success then success result is returned`() = runTest {
// Given
val callback = givenOnReAuthCallback()
val deviceIds = listOf(A_DEVICE_ID_1, A_DEVICE_ID_2)
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.givenDeleteDevicesSucceeds(deviceIds)
// When
val result = signoutSessionsUseCase.execute(deviceIds, callback)
// Then
result.isSuccess shouldBe true
verify {
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.deleteDevices(deviceIds, any(), any())
}
}
@Test
fun `given a list of device ids when signing out with error then failure result is returned`() = runTest {
// Given
val interceptor = givenOnReAuthCallback()
val deviceIds = listOf(A_DEVICE_ID_1, A_DEVICE_ID_2)
val error = mockk<Exception>()
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.givenDeleteDevicesFailsWithError(deviceIds, error)
// When
val result = signoutSessionsUseCase.execute(deviceIds, interceptor)
// Then
result.isFailure shouldBe true
verify {
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.deleteDevices(deviceIds, any(), any())
}
}
@Test
fun `given a list of device ids when signing out with reAuth needed then callback is called`() = runTest {
// Given
val callback = givenOnReAuthCallback()
val deviceIds = listOf(A_DEVICE_ID_1, A_DEVICE_ID_2)
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.givenDeleteDevicesNeedsUIAuth(deviceIds)
val reAuthNeeded = SignoutSessionsReAuthNeeded(
pendingAuth = mockk(),
uiaContinuation = mockk(),
flowResponse = mockk(),
errCode = "errorCode"
)
every { fakeInterceptSignoutFlowResponseUseCase.execute(any(), any(), any()) } returns reAuthNeeded
// When
val result = signoutSessionsUseCase.execute(deviceIds, callback)
// Then
result.isSuccess shouldBe true
verify {
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.deleteDevices(deviceIds, any(), any())
callback(reAuthNeeded)
}
}
private fun givenOnReAuthCallback(): (SignoutSessionsReAuthNeeded) -> Unit = {}
}

View file

@ -22,6 +22,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.session.crypto.CryptoService
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
@ -70,16 +71,21 @@ class FakeCryptoService(
}
}
fun givenDeleteDeviceSucceeds(deviceId: String) {
val matrixCallback = slot<MatrixCallback<Unit>>()
every { deleteDevice(deviceId, any(), capture(matrixCallback)) } answers {
fun givenDeleteDevicesSucceeds(deviceIds: List<String>) {
every { deleteDevices(deviceIds, any(), any()) } answers {
thirdArg<MatrixCallback<Unit>>().onSuccess(Unit)
}
}
fun givenDeleteDeviceFailsWithError(deviceId: String, error: Exception) {
val matrixCallback = slot<MatrixCallback<Unit>>()
every { deleteDevice(deviceId, any(), capture(matrixCallback)) } answers {
fun givenDeleteDevicesNeedsUIAuth(deviceIds: List<String>) {
every { deleteDevices(deviceIds, any(), any()) } answers {
secondArg<UserInteractiveAuthInterceptor>().performStage(mockk(), "", mockk())
thirdArg<MatrixCallback<Unit>>().onSuccess(Unit)
}
}
fun givenDeleteDevicesFailsWithError(deviceIds: List<String>, error: Exception) {
every { deleteDevices(deviceIds, any(), any()) } answers {
thirdArg<MatrixCallback<Unit>>().onFailure(error)
}
}

View file

@ -0,0 +1,55 @@
/*
* 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.fakes
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
class FakeSignoutSessionsUseCase {
val instance = mockk<SignoutSessionsUseCase>()
fun givenSignoutSuccess(deviceIds: List<String>) {
coEvery { instance.execute(deviceIds, any()) } returns Result.success(Unit)
}
fun givenSignoutReAuthNeeded(deviceIds: List<String>): SignoutSessionsReAuthNeeded {
val flowResponse = mockk<RegistrationFlowResponse>()
every { flowResponse.session } returns "a-session-id"
val errorCode = "errorCode"
val reAuthNeeded = SignoutSessionsReAuthNeeded(
pendingAuth = mockk(),
uiaContinuation = mockk(),
flowResponse = flowResponse,
errCode = errorCode,
)
coEvery { instance.execute(deviceIds, any()) } coAnswers {
secondArg<(SignoutSessionsReAuthNeeded) -> Unit>().invoke(reAuthNeeded)
Result.success(Unit)
}
return reAuthNeeded
}
fun givenSignoutError(deviceIds: List<String>, error: Throwable) {
coEvery { instance.execute(deviceIds, any()) } returns Result.failure(error)
}
}