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"}' headers: '{"GraphQL-Features": "projects_next_graphql"}'
query: | query: |
mutation add_to_project($projectid:ID!, $contentid:ID!) { mutation add_to_project($projectid:ID!, $contentid:ID!) {
addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) {
projectNextItem { item {
id id
} }
} }
@ -129,8 +129,8 @@ jobs:
headers: '{"GraphQL-Features": "projects_next_graphql"}' headers: '{"GraphQL-Features": "projects_next_graphql"}'
query: | query: |
mutation add_to_project($projectid:ID!, $contentid:ID!) { mutation add_to_project($projectid:ID!, $contentid:ID!) {
addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) {
projectNextItem { item {
id 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) 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 // 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 // the whole commit which set version 0.16.0-SNAPSHOT
def vanniktechEmoji = "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" def fragment = "1.5.4"
// Testing // 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 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_no_inactive_sessions_found">No inactive sessions found.</string>
<string name="device_manager_other_sessions_clear_filter">Clear Filter</string> <string name="device_manager_other_sessions_clear_filter">Clear Filter</string>
<string name="device_manager_other_sessions_select">Select sessions</string> <string name="device_manager_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_overview_signout">Sign out of this session</string>
<string name="device_manager_session_details_title">Session details</string> <string name="device_manager_session_details_title">Session details</string>
<string name="device_manager_session_details_description">Application, device, and activity information.</string> <string name="device_manager_session_details_description">Application, device, and activity information.</string>

View file

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

View file

@ -16,6 +16,7 @@
package org.matrix.android.sdk.session.search package org.matrix.android.sdk.session.search
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.FixMethodOrder import org.junit.FixMethodOrder
import org.junit.Test import org.junit.Test
@ -43,7 +44,7 @@ class SearchMessagesTest : InstrumentedTest {
cryptoTestData.firstSession cryptoTestData.firstSession
.searchService() .searchService()
.search( .search(
searchTerm = "lore", searchTerm = "lorem",
limit = 10, limit = 10,
includeProfile = true, includeProfile = true,
afterLimit = 0, afterLimit = 0,
@ -61,7 +62,7 @@ class SearchMessagesTest : InstrumentedTest {
cryptoTestData.firstSession cryptoTestData.firstSession
.searchService() .searchService()
.search( .search(
searchTerm = "lore", searchTerm = "lorem",
roomId = cryptoTestData.roomId, roomId = cryptoTestData.roomId,
limit = 10, limit = 10,
includeProfile = true, 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 cryptoTestData = cryptoTestHelper.doE2ETestWithAliceInARoom(false)
val aliceSession = cryptoTestData.firstSession val aliceSession = cryptoTestData.firstSession
val aliceRoomId = cryptoTestData.roomId val aliceRoomId = cryptoTestData.roomId
@ -87,7 +109,7 @@ class SearchMessagesTest : InstrumentedTest {
val data = block.invoke(cryptoTestData) val data = block.invoke(cryptoTestData)
assertTrue(data.results?.size == 2) data.results?.size shouldBeEqualTo expectedNumberOfResult
assertTrue( assertTrue(
data.results data.results
?.all { ?.all {

View file

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

View file

@ -242,8 +242,12 @@ internal class DefaultCryptoService @Inject constructor(
} }
override fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback<Unit>) { 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 deleteDeviceTask
.configureWith(DeleteDeviceTask.Params(deviceId, userInteractiveAuthInterceptor, null)) { .configureWith(DeleteDeviceTask.Params(deviceIds, userInteractiveAuthInterceptor, null)) {
this.executionThread = TaskThread.CRYPTO this.executionThread = TaskThread.CRYPTO
this.callback = callback 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.DeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.DevicesListResponse 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.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.KeyChangesResponse
import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimBody import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimBody
import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimResponse import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimResponse
@ -136,6 +137,17 @@ internal interface CryptoApi {
@Body params: DeleteDeviceParams @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. * Update the device information.
* Doc: https://matrix.org/docs/spec/client_server/r0.4.0.html#put-matrix-client-r0-devices-deviceid * 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) @JsonClass(generateAdapter = true)
internal data class DeleteDeviceParams( internal data class DeleteDeviceParams(
/**
* Additional authentication information for the user-interactive authentication API.
*/
@Json(name = "auth") @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 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.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.session.uia.UiaResult import org.matrix.android.sdk.api.session.uia.UiaResult
import org.matrix.android.sdk.internal.auth.registration.handleUIA 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.api.CryptoApi
import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams 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.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.task.Task
@ -30,7 +32,7 @@ import javax.inject.Inject
internal interface DeleteDeviceTask : Task<DeleteDeviceTask.Params, Unit> { internal interface DeleteDeviceTask : Task<DeleteDeviceTask.Params, Unit> {
data class Params( data class Params(
val deviceId: String, @Size(min = 1) val deviceIds: List<String>,
val userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor?, val userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor?,
val userAuthParam: UIABaseAuth? val userAuthParam: UIABaseAuth?
) )
@ -42,9 +44,24 @@ internal class DefaultDeleteDeviceTask @Inject constructor(
) : DeleteDeviceTask { ) : DeleteDeviceTask {
override suspend fun execute(params: DeleteDeviceTask.Params) { override suspend fun execute(params: DeleteDeviceTask.Params) {
require(params.deviceIds.isNotEmpty())
try { try {
executeRequest(globalErrorReceiver) { 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) { } catch (throwable: Throwable) {
if (params.userInteractiveAuthInterceptor == null || 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_ = "$URI_API_PREFIX_PATH/"
const val URI_API_PREFIX_PATH_R0 = "$URI_API_PREFIX_PATH/r0/" 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_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/" const val URI_API_PREFIX_PATH_UNSTABLE = "$URI_API_PREFIX_PATH/unstable/"
// Media // Media

View file

@ -67,7 +67,9 @@ internal object FilterFactory {
} }
private fun createElementTimelineFilter(): RoomEventFilter? { 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 { private fun createElementStateFilter(): RoomEventFilter {

View file

@ -132,7 +132,7 @@ dependencies {
implementation libs.androidx.biometric implementation libs.androidx.biometric
api "org.threeten:threetenbp:1.4.0:no-tzdb" 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 implementation libs.squareup.moshi
kapt libs.squareup.moshiKotlin 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. * definition. These properties must all be device independent.
*/ */
data class UserProperties( 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. * The active filter in the All Chats screen.
*/ */
@ -109,11 +89,6 @@ data class UserProperties(
fun getProperties(): Map<String, Any>? { fun getProperties(): Map<String, Any>? {
return mutableMapOf<String, Any>().apply { 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) } allChatsActiveFilter?.let { put("allChatsActiveFilter", it.name) }
ftueUseCaseSelection?.let { put("ftueUseCaseSelection", it.name) } ftueUseCaseSelection?.let { put("ftueUseCaseSelection", it.name) }
numFavouriteRooms?.let { put("numFavouriteRooms", it) } numFavouriteRooms?.let { put("numFavouriteRooms", it) }

View file

@ -1169,6 +1169,9 @@ class TimelineFragment :
lazyLoadedViews.inviteView(false)?.isVisible = false lazyLoadedViews.inviteView(false)?.isVisible = false
if (mainState.tombstoneEvent == null) { if (mainState.tombstoneEvent == null) {
views.composerContainer.isInvisible = !messageComposerState.isComposerVisible
views.voiceMessageRecorderContainer.isVisible = messageComposerState.isVoiceMessageRecorderVisible
when (messageComposerState.canSendMessage) { when (messageComposerState.canSendMessage) {
CanSendStatus.Allowed -> { CanSendStatus.Allowed -> {
NotificationAreaView.State.Hidden NotificationAreaView.State.Hidden
@ -1224,6 +1227,7 @@ class TimelineFragment :
private fun FragmentTimelineBinding.hideComposerViews() { private fun FragmentTimelineBinding.hideComposerViews() {
composerContainer.isVisible = false composerContainer.isVisible = false
voiceMessageRecorderContainer.isVisible = false
} }
private fun renderTypingMessageNotification(roomSummary: RoomSummary?, state: RoomDetailViewState) { 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 pillSpan = linkSpan.createPillSpan(roomId) ?: return@forEach
val startSpan = renderedText.getSpanStart(linkSpan) val startSpan = renderedText.getSpanStart(linkSpan)
val endSpan = renderedText.getSpanEnd(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) 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 import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
sealed class DevicesAction : VectorViewModelAction { sealed class DevicesAction : VectorViewModelAction {
// ReAuth
object SsoAuthDone : DevicesAction()
data class PasswordAuthDone(val password: String) : DevicesAction()
object ReAuthCancelled : DevicesAction()
// Others
object VerifyCurrentSession : DevicesAction() object VerifyCurrentSession : DevicesAction()
data class MarkAsManuallyVerified(val cryptoDeviceInfo: CryptoDeviceInfo) : 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 im.vector.app.core.platform.VectorViewEvents
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse 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.CryptoDeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
sealed class DevicesViewEvent : VectorViewEvents { sealed class DevicesViewEvent : VectorViewEvents {
data class Loading(val message: CharSequence? = null) : DevicesViewEvent() data class RequestReAuth(
data class Failure(val throwable: Throwable) : DevicesViewEvent() val registrationFlowResponse: RegistrationFlowResponse,
data class RequestReAuth(val registrationFlowResponse: RegistrationFlowResponse, val lastErrorCode: String?) : DevicesViewEvent() val lastErrorCode: String?
data class PromptRenameDevice(val deviceInfo: DeviceInfo) : DevicesViewEvent() ) : DevicesViewEvent()
data class ShowVerifyDevice(val userId: String, val transactionId: String?) : DevicesViewEvent() data class ShowVerifyDevice(val userId: String, val transactionId: String?) : DevicesViewEvent()
object SelfVerification : DevicesViewEvent() object SelfVerification : DevicesViewEvent()
data class ShowManuallyVerify(val cryptoDeviceInfo: CryptoDeviceInfo) : DevicesViewEvent() data class ShowManuallyVerify(val cryptoDeviceInfo: CryptoDeviceInfo) : DevicesViewEvent()
object PromptResetSecrets : 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.ActiveSessionHolder
import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory 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.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.CheckIfCurrentSessionCanBeVerifiedUseCase
import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.orFalse 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( class DevicesViewModel @AssistedInject constructor(
@Assisted initialState: DevicesViewState, @Assisted initialState: DevicesViewState,
@ -39,6 +45,9 @@ class DevicesViewModel @AssistedInject constructor(
private val getDeviceFullInfoListUseCase: GetDeviceFullInfoListUseCase, private val getDeviceFullInfoListUseCase: GetDeviceFullInfoListUseCase,
private val refreshDevicesOnCryptoDevicesChangeUseCase: RefreshDevicesOnCryptoDevicesChangeUseCase, private val refreshDevicesOnCryptoDevicesChangeUseCase: RefreshDevicesOnCryptoDevicesChangeUseCase,
private val checkIfCurrentSessionCanBeVerifiedUseCase: CheckIfCurrentSessionCanBeVerifiedUseCase, private val checkIfCurrentSessionCanBeVerifiedUseCase: CheckIfCurrentSessionCanBeVerifiedUseCase,
private val signoutSessionsUseCase: SignoutSessionsUseCase,
private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase,
private val pendingAuthHandler: PendingAuthHandler,
refreshDevicesUseCase: RefreshDevicesUseCase, refreshDevicesUseCase: RefreshDevicesUseCase,
) : VectorSessionsListViewModel<DevicesViewState, DevicesAction, DevicesViewEvent>(initialState, activeSessionHolder, refreshDevicesUseCase) { ) : VectorSessionsListViewModel<DevicesViewState, DevicesAction, DevicesViewEvent>(initialState, activeSessionHolder, refreshDevicesUseCase) {
@ -97,8 +106,12 @@ class DevicesViewModel @AssistedInject constructor(
override fun handle(action: DevicesAction) { override fun handle(action: DevicesAction) {
when (action) { when (action) {
is DevicesAction.PasswordAuthDone -> handlePasswordAuthDone(action)
DevicesAction.ReAuthCancelled -> handleReAuthCancelled()
DevicesAction.SsoAuthDone -> handleSsoAuthDone()
is DevicesAction.VerifyCurrentSession -> handleVerifyCurrentSessionAction() is DevicesAction.VerifyCurrentSession -> handleVerifyCurrentSessionAction()
is DevicesAction.MarkAsManuallyVerified -> handleMarkAsManuallyVerifiedAction() is DevicesAction.MarkAsManuallyVerified -> handleMarkAsManuallyVerifiedAction()
DevicesAction.MultiSignoutOtherSessions -> handleMultiSignoutOtherSessions()
} }
} }
@ -116,4 +129,66 @@ class DevicesViewModel @AssistedInject constructor(
private fun handleMarkAsManuallyVerifiedAction() { private fun handleMarkAsManuallyVerifiedAction() {
// TODO implement when needed // 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 package im.vector.app.features.settings.devices.v2
import android.app.Activity
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@ -30,12 +31,15 @@ import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.dialogs.ManuallyVerifyDialog 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.platform.VectorBaseFragment
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.DrawableProvider
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.databinding.FragmentSettingsDevicesBinding import im.vector.app.databinding.FragmentSettingsDevicesBinding
import im.vector.app.features.VectorFeatures 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.recover.SetupMode
import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.crypto.verification.VerificationBottomSheet
import im.vector.app.features.login.qr.QrCodeLoginArgs 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.SecurityRecommendationView
import im.vector.app.features.settings.devices.v2.list.SecurityRecommendationViewState 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.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 org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import javax.inject.Inject import javax.inject.Inject
@ -70,6 +76,8 @@ class VectorSettingsDevicesFragment :
@Inject lateinit var stringProvider: StringProvider @Inject lateinit var stringProvider: StringProvider
@Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase
private val viewModel: DevicesViewModel by fragmentViewModel() private val viewModel: DevicesViewModel by fragmentViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSettingsDevicesBinding { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSettingsDevicesBinding {
@ -91,6 +99,7 @@ class VectorSettingsDevicesFragment :
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
initWaitingView() initWaitingView()
initOtherSessionsHeaderView()
initOtherSessionsView() initOtherSessionsView()
initSecurityRecommendationsView() initSecurityRecommendationsView()
initQrLoginView() initQrLoginView()
@ -100,10 +109,7 @@ class VectorSettingsDevicesFragment :
private fun observeViewEvents() { private fun observeViewEvents() {
viewModel.observeViewEvents { viewModel.observeViewEvents {
when (it) { when (it) {
is DevicesViewEvent.Loading -> showLoading(it.message) is DevicesViewEvent.RequestReAuth -> askForReAuthentication(it)
is DevicesViewEvent.Failure -> showFailure(it.throwable)
is DevicesViewEvent.RequestReAuth -> Unit // TODO. Next PR
is DevicesViewEvent.PromptRenameDevice -> Unit // TODO. Next PR
is DevicesViewEvent.ShowVerifyDevice -> { is DevicesViewEvent.ShowVerifyDevice -> {
VerificationBottomSheet.withArgs( VerificationBottomSheet.withArgs(
roomId = null, roomId = null,
@ -122,6 +128,8 @@ class VectorSettingsDevicesFragment :
is DevicesViewEvent.PromptResetSecrets -> { is DevicesViewEvent.PromptResetSecrets -> {
navigator.open4SSetup(requireActivity(), SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET) 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 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() { private fun initOtherSessionsView() {
views.deviceListOtherSessions.callback = this views.deviceListOtherSessions.callback = this
} }
@ -142,7 +173,7 @@ class VectorSettingsDevicesFragment :
requireActivity(), requireActivity(),
R.string.device_manager_header_section_security_recommendations_title, R.string.device_manager_header_section_security_recommendations_title,
DeviceManagerFilterType.UNVERIFIED, DeviceManagerFilterType.UNVERIFIED,
excludeCurrentDevice = false excludeCurrentDevice = true
) )
} }
} }
@ -152,7 +183,7 @@ class VectorSettingsDevicesFragment :
requireActivity(), requireActivity(),
R.string.device_manager_header_section_security_recommendations_title, R.string.device_manager_header_section_security_recommendations_title,
DeviceManagerFilterType.INACTIVE, DeviceManagerFilterType.INACTIVE,
excludeCurrentDevice = false excludeCurrentDevice = true
) )
} }
} }
@ -271,6 +302,11 @@ class VectorSettingsDevicesFragment :
hideOtherSessionsView() hideOtherSessionsView()
} else { } else {
views.deviceListHeaderOtherSessions.isVisible = true 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.isVisible = true
views.deviceListOtherSessions.render( views.deviceListOtherSessions.render(
devices = otherDevices.take(NUMBER_OF_OTHER_DEVICES_TO_RENDER), devices = otherDevices.take(NUMBER_OF_OTHER_DEVICES_TO_RENDER),
@ -347,4 +383,37 @@ class VectorSettingsDevicesFragment :
excludeCurrentDevice = true 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.content.res.TypedArray
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater 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.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.use import androidx.core.content.res.use
import androidx.core.view.isVisible import androidx.core.view.isVisible
@ -39,6 +43,7 @@ class SessionsListHeaderView @JvmOverloads constructor(
this this
) )
val menu: Menu = binding.sessionsListHeaderMenu.menu
var onLearnMoreClickListener: (() -> Unit)? = null var onLearnMoreClickListener: (() -> Unit)? = null
init { init {
@ -50,6 +55,7 @@ class SessionsListHeaderView @JvmOverloads constructor(
).use { ).use {
setTitle(it) setTitle(it)
setDescription(it) setDescription(it)
setMenu(it)
} }
} }
@ -90,4 +96,19 @@ class SessionsListHeaderView @JvmOverloads constructor(
onLearnMoreClickListener?.invoke() 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 import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType
sealed class OtherSessionsAction : VectorViewModelAction { 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 FilterDevices(val filterType: DeviceManagerFilterType) : OtherSessionsAction()
data class EnableSelectMode(val deviceId: String?) : OtherSessionsAction() data class EnableSelectMode(val deviceId: String?) : OtherSessionsAction()
object DisableSelectMode : OtherSessionsAction() object DisableSelectMode : OtherSessionsAction()
data class ToggleSelectionForDevice(val deviceId: String) : OtherSessionsAction() data class ToggleSelectionForDevice(val deviceId: String) : OtherSessionsAction()
object SelectAll : OtherSessionsAction() object SelectAll : OtherSessionsAction()
object DeselectAll : OtherSessionsAction() object DeselectAll : OtherSessionsAction()
object MultiSignout : OtherSessionsAction()
} }

View file

@ -16,6 +16,7 @@
package im.vector.app.features.settings.devices.v2.othersessions package im.vector.app.features.settings.devices.v2.othersessions
import android.app.Activity
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
@ -32,6 +33,8 @@ import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R 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
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment.ResultListener.Companion.RESULT_OK import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment.ResultListener.Companion.RESULT_OK
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
@ -39,13 +42,16 @@ import im.vector.app.core.platform.VectorMenuProvider
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.databinding.FragmentOtherSessionsBinding 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.DeviceFullInfo
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterBottomSheet 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.filter.DeviceManagerFilterType
import im.vector.app.features.settings.devices.v2.list.OtherSessionsView import im.vector.app.features.settings.devices.v2.list.OtherSessionsView
import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS
import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet
import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase
import im.vector.app.features.themes.ThemeUtils 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 org.matrix.android.sdk.api.extensions.orFalse
import javax.inject.Inject import javax.inject.Inject
@ -65,6 +71,8 @@ class OtherSessionsFragment :
@Inject lateinit var viewNavigator: OtherSessionsViewNavigator @Inject lateinit var viewNavigator: OtherSessionsViewNavigator
@Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentOtherSessionsBinding { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentOtherSessionsBinding {
return FragmentOtherSessionsBinding.inflate(layoutInflater, container, false) return FragmentOtherSessionsBinding.inflate(layoutInflater, container, false)
} }
@ -77,9 +85,33 @@ class OtherSessionsFragment :
menu.findItem(R.id.otherSessionsSelectAll).isVisible = isSelectModeEnabled menu.findItem(R.id.otherSessionsSelectAll).isVisible = isSelectModeEnabled
menu.findItem(R.id.otherSessionsDeselectAll).isVisible = isSelectModeEnabled menu.findItem(R.id.otherSessionsDeselectAll).isVisible = isSelectModeEnabled
menu.findItem(R.id.otherSessionsSelect).isVisible = !isSelectModeEnabled && state.devices()?.isNotEmpty().orFalse() 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 { override fun handleMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.otherSessionsSelect -> { R.id.otherSessionsSelect -> {
@ -94,10 +126,25 @@ class OtherSessionsFragment :
viewModel.handle(OtherSessionsAction.DeselectAll) viewModel.handle(OtherSessionsAction.DeselectAll)
true true
} }
R.id.otherSessionsMultiSignout -> {
confirmMultiSignout()
true
}
else -> false 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) { private fun enableSelectMode(isEnabled: Boolean, deviceId: String? = null) {
val action = if (isEnabled) OtherSessionsAction.EnableSelectMode(deviceId) else OtherSessionsAction.DisableSelectMode val action = if (isEnabled) OtherSessionsAction.EnableSelectMode(deviceId) else OtherSessionsAction.DisableSelectMode
viewModel.handle(action) viewModel.handle(action)
@ -129,8 +176,9 @@ class OtherSessionsFragment :
private fun observeViewEvents() { private fun observeViewEvents() {
viewModel.observeViewEvents { viewModel.observeViewEvents {
when (it) { when (it) {
is OtherSessionsViewEvents.Loading -> showLoading(it.message) is OtherSessionsViewEvents.SignoutError -> showFailure(it.error)
is OtherSessionsViewEvents.Failure -> showFailure(it.throwable) is OtherSessionsViewEvents.RequestReAuth -> askForReAuthentication(it)
OtherSessionsViewEvents.SignoutSuccess -> enableSelectMode(false)
} }
} }
} }
@ -162,6 +210,7 @@ class OtherSessionsFragment :
} }
override fun invalidate() = withState(viewModel) { state -> override fun invalidate() = withState(viewModel) { state ->
updateLoading(state.isLoading)
if (state.devices is Success) { if (state.devices is Success) {
val devices = state.devices.invoke() val devices = state.devices.invoke()
renderDevices(devices, state.currentFilter) 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) { private fun updateToolbar(devices: List<DeviceFullInfo>, isSelectModeEnabled: Boolean) {
invalidateOptionsMenu() invalidateOptionsMenu()
val title = if (isSelectModeEnabled) { val title = if (isSelectModeEnabled) {
@ -286,4 +343,37 @@ class OtherSessionsFragment :
override fun onViewAllOtherSessionsClicked() { override fun onViewAllOtherSessionsClicked() {
// NOOP. We don't have this button in this screen // NOOP. We don't have this button in this screen
} }
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 package im.vector.app.features.settings.devices.v2.othersessions
import im.vector.app.core.platform.VectorViewEvents import im.vector.app.core.platform.VectorViewEvents
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
sealed class OtherSessionsViewEvents : VectorViewEvents { sealed class OtherSessionsViewEvents : VectorViewEvents {
data class Loading(val message: CharSequence? = null) : OtherSessionsViewEvents() data class RequestReAuth(
data class Failure(val throwable: Throwable) : OtherSessionsViewEvents() 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.ActiveSessionHolder
import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory 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.GetDeviceFullInfoListUseCase
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase 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.VectorSessionsListViewModel
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType 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.Job
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
import timber.log.Timber
class OtherSessionsViewModel @AssistedInject constructor( class OtherSessionsViewModel @AssistedInject constructor(
@Assisted private val initialState: OtherSessionsViewState, @Assisted private val initialState: OtherSessionsViewState,
activeSessionHolder: ActiveSessionHolder, activeSessionHolder: ActiveSessionHolder,
private val getDeviceFullInfoListUseCase: GetDeviceFullInfoListUseCase, private val getDeviceFullInfoListUseCase: GetDeviceFullInfoListUseCase,
private val signoutSessionsUseCase: SignoutSessionsUseCase,
private val pendingAuthHandler: PendingAuthHandler,
refreshDevicesUseCase: RefreshDevicesUseCase refreshDevicesUseCase: RefreshDevicesUseCase
) : VectorSessionsListViewModel<OtherSessionsViewState, OtherSessionsAction, OtherSessionsViewEvents>( ) : VectorSessionsListViewModel<OtherSessionsViewState, OtherSessionsAction, OtherSessionsViewEvents>(
initialState, activeSessionHolder, refreshDevicesUseCase initialState, activeSessionHolder, refreshDevicesUseCase
@ -67,12 +75,16 @@ class OtherSessionsViewModel @AssistedInject constructor(
override fun handle(action: OtherSessionsAction) { override fun handle(action: OtherSessionsAction) {
when (action) { when (action) {
is OtherSessionsAction.PasswordAuthDone -> handlePasswordAuthDone(action)
OtherSessionsAction.ReAuthCancelled -> handleReAuthCancelled()
OtherSessionsAction.SsoAuthDone -> handleSsoAuthDone()
is OtherSessionsAction.FilterDevices -> handleFilterDevices(action) is OtherSessionsAction.FilterDevices -> handleFilterDevices(action)
OtherSessionsAction.DisableSelectMode -> handleDisableSelectMode() OtherSessionsAction.DisableSelectMode -> handleDisableSelectMode()
is OtherSessionsAction.EnableSelectMode -> handleEnableSelectMode(action.deviceId) is OtherSessionsAction.EnableSelectMode -> handleEnableSelectMode(action.deviceId)
is OtherSessionsAction.ToggleSelectionForDevice -> handleToggleSelectionForDevice(action.deviceId) is OtherSessionsAction.ToggleSelectionForDevice -> handleToggleSelectionForDevice(action.deviceId)
OtherSessionsAction.DeselectAll -> handleDeselectAll() OtherSessionsAction.DeselectAll -> handleDeselectAll()
OtherSessionsAction.SelectAll -> handleSelectAll() 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 currentFilter: DeviceManagerFilterType = DeviceManagerFilterType.ALL_SESSIONS,
val excludeCurrentDevice: Boolean = false, val excludeCurrentDevice: Boolean = false,
val isSelectModeEnabled: Boolean = false, val isSelectModeEnabled: Boolean = false,
val isLoading: Boolean = false,
) : MavericksState { ) : MavericksState {
constructor(args: OtherSessionsArgs) : this(excludeCurrentDevice = args.excludeCurrentDevice) 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.Success
import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.date.VectorDateFormatter 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.list.SessionInfoViewState
import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet
import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus 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 im.vector.app.features.workers.signout.SignOutUiWorker
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
@ -69,6 +69,8 @@ class SessionOverviewFragment :
@Inject lateinit var stringProvider: StringProvider @Inject lateinit var stringProvider: StringProvider
@Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase
private val viewModel: SessionOverviewViewModel by fragmentViewModel() private val viewModel: SessionOverviewViewModel by fragmentViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSessionOverviewBinding { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSessionOverviewBinding {
@ -134,13 +136,7 @@ class SessionOverviewFragment :
private fun confirmSignoutOtherSession() { private fun confirmSignoutOtherSession() {
activity?.let { activity?.let {
MaterialAlertDialogBuilder(it) buildConfirmSignoutDialogUseCase.execute(it, this::signoutSession)
.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)
.show() .show()
} }
} }

View file

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

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

View file

@ -13,7 +13,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/layout_horizontal_margin" android:layout_marginHorizontal="@dimen/layout_horizontal_margin"
android:layout_marginTop="20dp" android:layout_marginTop="20dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toStartOf="@id/sessionsListHeaderMenu"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:text="Other sessions" /> tools:text="Other sessions" />
@ -29,4 +29,13 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/sessions_list_header_title" 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." /> 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> </merge>

View file

@ -9,6 +9,11 @@
android:title="@string/device_manager_other_sessions_select" android:title="@string/device_manager_other_sessions_select"
app:showAsAction="withText|never" /> app:showAsAction="withText|never" />
<item
android:id="@+id/otherSessionsMultiSignout"
android:title="@plurals/device_manager_other_sessions_multi_signout_all"
app:showAsAction="withText|never" />
<item <item
android:id="@+id/otherSessionsSelectAll" android:id="@+id/otherSessionsSelectAll"
android:title="@string/action_select_all" 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.core.session.clientinfo.MatrixClientInfoContent
import im.vector.app.features.settings.devices.v2.details.extended.DeviceExtendedInfo 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.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.CheckIfCurrentSessionCanBeVerifiedUseCase
import im.vector.app.features.settings.devices.v2.verification.CurrentSessionCrossSigningInfo import im.vector.app.features.settings.devices.v2.verification.CurrentSessionCrossSigningInfo
import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder 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.fakes.FakeVerificationService
import im.vector.app.test.test import im.vector.app.test.test
import im.vector.app.test.testDispatcher import im.vector.app.test.testDispatcher
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.justRun
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkAll import io.mockk.unmockkAll
import io.mockk.verify import io.mockk.verify
import io.mockk.verifyAll
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel 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.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.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 { class DevicesViewModelTest {
@ -55,19 +66,25 @@ class DevicesViewModelTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder() private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val getCurrentSessionCrossSigningInfoUseCase = mockk<GetCurrentSessionCrossSigningInfoUseCase>() private val getCurrentSessionCrossSigningInfoUseCase = mockk<GetCurrentSessionCrossSigningInfoUseCase>()
private val getDeviceFullInfoListUseCase = mockk<GetDeviceFullInfoListUseCase>() private val getDeviceFullInfoListUseCase = mockk<GetDeviceFullInfoListUseCase>()
private val refreshDevicesUseCase = mockk<RefreshDevicesUseCase>(relaxUnitFun = true) private val refreshDevicesOnCryptoDevicesChangeUseCase = mockk<RefreshDevicesOnCryptoDevicesChangeUseCase>(relaxed = true)
private val refreshDevicesOnCryptoDevicesChangeUseCase = mockk<RefreshDevicesOnCryptoDevicesChangeUseCase>()
private val checkIfCurrentSessionCanBeVerifiedUseCase = mockk<CheckIfCurrentSessionCanBeVerifiedUseCase>() 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 { private fun createViewModel(): DevicesViewModel {
return DevicesViewModel( return DevicesViewModel(
DevicesViewState(), initialState = DevicesViewState(),
fakeActiveSessionHolder.instance, activeSessionHolder = fakeActiveSessionHolder.instance,
getCurrentSessionCrossSigningInfoUseCase, getCurrentSessionCrossSigningInfoUseCase = getCurrentSessionCrossSigningInfoUseCase,
getDeviceFullInfoListUseCase, getDeviceFullInfoListUseCase = getDeviceFullInfoListUseCase,
refreshDevicesOnCryptoDevicesChangeUseCase, refreshDevicesOnCryptoDevicesChangeUseCase = refreshDevicesOnCryptoDevicesChangeUseCase,
checkIfCurrentSessionCanBeVerifiedUseCase, checkIfCurrentSessionCanBeVerifiedUseCase = checkIfCurrentSessionCanBeVerifiedUseCase,
refreshDevicesUseCase, 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 // Needed for internal usage of Flow<T>.throttleFirst() inside the ViewModel
mockkStatic(SystemClock::class) mockkStatic(SystemClock::class)
every { SystemClock.elapsedRealtime() } returns 1234 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 @After
@ -87,9 +118,6 @@ class DevicesViewModelTest {
fun `given the viewModel when initializing it then verification listener is added`() { fun `given the viewModel when initializing it then verification listener is added`() {
// Given // Given
val fakeVerificationService = givenVerificationService() val fakeVerificationService = givenVerificationService()
givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
// When // When
val viewModel = createViewModel() val viewModel = createViewModel()
@ -104,9 +132,6 @@ class DevicesViewModelTest {
fun `given the viewModel when clearing it then verification listener is removed`() { fun `given the viewModel when clearing it then verification listener is removed`() {
// Given // Given
val fakeVerificationService = givenVerificationService() val fakeVerificationService = givenVerificationService()
givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
// When // When
val viewModel = createViewModel() val viewModel = createViewModel()
@ -121,10 +146,7 @@ class DevicesViewModelTest {
@Test @Test
fun `given the viewModel when initializing it then view state is updated with current session cross signing info`() { fun `given the viewModel when initializing it then view state is updated with current session cross signing info`() {
// Given // Given
givenVerificationService()
val currentSessionCrossSigningInfo = givenCurrentSessionCrossSigningInfo() val currentSessionCrossSigningInfo = givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
// When // When
val viewModelTest = createViewModel().test() val viewModelTest = createViewModel().test()
@ -137,10 +159,7 @@ class DevicesViewModelTest {
@Test @Test
fun `given the viewModel when initializing it then view state is updated with current device full info list`() { fun `given the viewModel when initializing it then view state is updated with current device full info list`() {
// Given // Given
givenVerificationService() val deviceFullInfoList = givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2)
givenCurrentSessionCrossSigningInfo()
val deviceFullInfoList = givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
// When // When
val viewModelTest = createViewModel().test() val viewModelTest = createViewModel().test()
@ -156,10 +175,6 @@ class DevicesViewModelTest {
@Test @Test
fun `given the viewModel when initializing it then devices are refreshed on crypto devices change`() { fun `given the viewModel when initializing it then devices are refreshed on crypto devices change`() {
// Given // Given
givenVerificationService()
givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
// When // When
createViewModel() createViewModel()
@ -171,10 +186,6 @@ class DevicesViewModelTest {
@Test @Test
fun `given current session can be verified when handling verify current session action then self verification event is posted`() { fun `given current session can be verified when handling verify current session action then self verification event is posted`() {
// Given // Given
givenVerificationService()
givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
val verifyCurrentSessionAction = DevicesAction.VerifyCurrentSession val verifyCurrentSessionAction = DevicesAction.VerifyCurrentSession
coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns true coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns true
@ -195,10 +206,6 @@ class DevicesViewModelTest {
@Test @Test
fun `given current session cannot be verified when handling verify current session action then reset secrets event is posted`() { fun `given current session cannot be verified when handling verify current session action then reset secrets event is posted`() {
// Given // Given
givenVerificationService()
givenCurrentSessionCrossSigningInfo()
givenDeviceFullInfoList()
givenRefreshDevicesOnCryptoDevicesChange()
val verifyCurrentSessionAction = DevicesAction.VerifyCurrentSession val verifyCurrentSessionAction = DevicesAction.VerifyCurrentSession
coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns false coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns false
@ -216,18 +223,129 @@ class DevicesViewModelTest {
} }
} }
private fun givenVerificationService(): FakeVerificationService { @Test
val fakeVerificationService = fakeActiveSessionHolder fun `given no reAuth is needed when handling multiSignout other sessions action then signout process is performed`() {
.fakeSession // Given
.fakeCryptoService val expectedViewState = givenInitialViewState(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_CURRENT_DEVICE_ID)
.fakeVerificationService // signout all devices except the current device
fakeVerificationService.givenAddListenerSucceeds() fakeSignoutSessionsUseCase.givenSignoutSuccess(listOf(A_DEVICE_ID_1))
fakeVerificationService.givenRemoveListenerSucceeds()
return fakeVerificationService // 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 { private fun givenCurrentSessionCrossSigningInfo(): CurrentSessionCrossSigningInfo {
val currentSessionCrossSigningInfo = mockk<CurrentSessionCrossSigningInfo>() val currentSessionCrossSigningInfo = mockk<CurrentSessionCrossSigningInfo>()
every { currentSessionCrossSigningInfo.deviceId } returns A_CURRENT_DEVICE_ID
every { getCurrentSessionCrossSigningInfoUseCase.execute() } returns flowOf(currentSessionCrossSigningInfo) every { getCurrentSessionCrossSigningInfoUseCase.execute() } returns flowOf(currentSessionCrossSigningInfo)
return currentSessionCrossSigningInfo return currentSessionCrossSigningInfo
} }
@ -235,14 +353,19 @@ class DevicesViewModelTest {
/** /**
* Generate mocked deviceFullInfo list with 1 unverified and inactive + 1 verified and active. * 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>() val verifiedCryptoDeviceInfo = mockk<CryptoDeviceInfo>()
every { verifiedCryptoDeviceInfo.trustLevel } returns DeviceTrustLevel(crossSigningVerified = true, locallyVerified = true) every { verifiedCryptoDeviceInfo.trustLevel } returns DeviceTrustLevel(crossSigningVerified = true, locallyVerified = true)
val unverifiedCryptoDeviceInfo = mockk<CryptoDeviceInfo>() val unverifiedCryptoDeviceInfo = mockk<CryptoDeviceInfo>()
every { unverifiedCryptoDeviceInfo.trustLevel } returns DeviceTrustLevel(crossSigningVerified = false, locallyVerified = false) 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( val deviceFullInfo1 = DeviceFullInfo(
deviceInfo = mockk(), deviceInfo = deviceInfo1,
cryptoDeviceInfo = verifiedCryptoDeviceInfo, cryptoDeviceInfo = verifiedCryptoDeviceInfo,
roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted,
isInactive = false, isInactive = false,
@ -251,7 +374,7 @@ class DevicesViewModelTest {
matrixClientInfo = MatrixClientInfoContent(), matrixClientInfo = MatrixClientInfoContent(),
) )
val deviceFullInfo2 = DeviceFullInfo( val deviceFullInfo2 = DeviceFullInfo(
deviceInfo = mockk(), deviceInfo = deviceInfo2,
cryptoDeviceInfo = unverifiedCryptoDeviceInfo, cryptoDeviceInfo = unverifiedCryptoDeviceInfo,
roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Warning, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Warning,
isInactive = true, isInactive = true,
@ -265,7 +388,15 @@ class DevicesViewModelTest {
return deviceFullInfoList return deviceFullInfoList
} }
private fun givenRefreshDevicesOnCryptoDevicesChange() { private fun givenInitialViewState(deviceId1: String, deviceId2: String): DevicesViewState {
coEvery { refreshDevicesOnCryptoDevicesChangeUseCase.execute() } just runs 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.RefreshDevicesUseCase
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType
import im.vector.app.test.fakes.FakeActiveSessionHolder 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.fakes.FakeVerificationService
import im.vector.app.test.fixtures.aDeviceFullInfo import im.vector.app.test.fixtures.aDeviceFullInfo
import im.vector.app.test.test import im.vector.app.test.test
import im.vector.app.test.testDispatcher import im.vector.app.test.testDispatcher
import io.mockk.every import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkAll import io.mockk.unmockkAll
import io.mockk.verify
import io.mockk.verifyAll import io.mockk.verifyAll
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test 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_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 { class OtherSessionsViewModelTest {
@ -55,14 +63,19 @@ class OtherSessionsViewModelTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder() private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val fakeGetDeviceFullInfoListUseCase = mockk<GetDeviceFullInfoListUseCase>() 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( private fun createViewModel(viewState: OtherSessionsViewState = OtherSessionsViewState(defaultArgs)) =
initialState = OtherSessionsViewState(args), OtherSessionsViewModel(
activeSessionHolder = fakeActiveSessionHolder.instance, initialState = viewState,
getDeviceFullInfoListUseCase = fakeGetDeviceFullInfoListUseCase, activeSessionHolder = fakeActiveSessionHolder.instance,
refreshDevicesUseCase = fakeRefreshDevicesUseCaseUseCase, getDeviceFullInfoListUseCase = fakeGetDeviceFullInfoListUseCase,
) signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance,
pendingAuthHandler = fakePendingAuthHandler.instance,
refreshDevicesUseCase = fakeRefreshDevicesUseCase,
)
@Before @Before
fun setup() { fun setup() {
@ -88,6 +101,39 @@ class OtherSessionsViewModelTest {
unmockkAll() 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 @Test
fun `given the viewModel has been initialized then viewState is updated with devices list`() { fun `given the viewModel has been initialized then viewState is updated with devices list`() {
// Given // Given
@ -143,7 +189,7 @@ class OtherSessionsViewModelTest {
@Test @Test
fun `given enable select mode action when handling the action then viewState is updated with correct info`() { fun `given enable select mode action when handling the action then viewState is updated with correct info`() {
// Given // Given
val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID, isSelected = false) val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo) val devices: List<DeviceFullInfo> = listOf(deviceFullInfo)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState( val expectedState = OtherSessionsViewState(
@ -156,7 +202,7 @@ class OtherSessionsViewModelTest {
// When // When
val viewModel = createViewModel() val viewModel = createViewModel()
val viewModelTest = viewModel.test() val viewModelTest = viewModel.test()
viewModel.handle(OtherSessionsAction.EnableSelectMode(A_DEVICE_ID)) viewModel.handle(OtherSessionsAction.EnableSelectMode(A_DEVICE_ID_1))
// Then // Then
viewModelTest viewModelTest
@ -167,8 +213,8 @@ class OtherSessionsViewModelTest {
@Test @Test
fun `given disable select mode action when handling the action then viewState is updated with correct info`() { fun `given disable select mode action when handling the action then viewState is updated with correct info`() {
// Given // Given
val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true) val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = true)
val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true) val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = true)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2) val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState( val expectedState = OtherSessionsViewState(
@ -192,7 +238,7 @@ class OtherSessionsViewModelTest {
@Test @Test
fun `given toggle selection for device action when handling the action then viewState is updated with correct info`() { fun `given toggle selection for device action when handling the action then viewState is updated with correct info`() {
// Given // Given
val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID, isSelected = false) val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo) val devices: List<DeviceFullInfo> = listOf(deviceFullInfo)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState( val expectedState = OtherSessionsViewState(
@ -205,7 +251,7 @@ class OtherSessionsViewModelTest {
// When // When
val viewModel = createViewModel() val viewModel = createViewModel()
val viewModelTest = viewModel.test() val viewModelTest = viewModel.test()
viewModel.handle(OtherSessionsAction.ToggleSelectionForDevice(A_DEVICE_ID)) viewModel.handle(OtherSessionsAction.ToggleSelectionForDevice(A_DEVICE_ID_1))
// Then // Then
viewModelTest viewModelTest
@ -216,8 +262,8 @@ class OtherSessionsViewModelTest {
@Test @Test
fun `given select all action when handling the action then viewState is updated with correct info`() { fun `given select all action when handling the action then viewState is updated with correct info`() {
// Given // Given
val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID, isSelected = false) val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true) val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2) val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState( val expectedState = OtherSessionsViewState(
@ -241,8 +287,8 @@ class OtherSessionsViewModelTest {
@Test @Test
fun `given deselect all action when handling the action then viewState is updated with correct info`() { fun `given deselect all action when handling the action then viewState is updated with correct info`() {
// Given // Given
val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID, isSelected = false) val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true) val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true)
val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2) val devices: List<DeviceFullInfo> = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState( val expectedState = OtherSessionsViewState(
@ -263,6 +309,190 @@ class OtherSessionsViewModelTest {
.finish() .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( private fun givenGetDeviceFullInfoListReturns(
filterType: DeviceManagerFilterType, filterType: DeviceManagerFilterType,
devices: List<DeviceFullInfo>, devices: List<DeviceFullInfo>,

View file

@ -20,18 +20,15 @@ import android.os.SystemClock
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.airbnb.mvrx.Success import com.airbnb.mvrx.Success
import com.airbnb.mvrx.test.MavericksTestRule 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.DeviceFullInfo
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase 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.GetNotificationsStatusUseCase
import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus 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.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.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakePendingAuthHandler 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.FakeTogglePushNotificationUseCase
import im.vector.app.test.fakes.FakeVerificationService import im.vector.app.test.fakes.FakeVerificationService
import im.vector.app.test.test import im.vector.app.test.test
@ -43,7 +40,6 @@ import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.runs import io.mockk.runs
import io.mockk.slot
import io.mockk.unmockkAll import io.mockk.unmockkAll
import io.mockk.verify import io.mockk.verify
import io.mockk.verifyAll import io.mockk.verifyAll
@ -53,19 +49,11 @@ import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test 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.crypto.model.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth 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_1 = "session-id-1"
private const val A_SESSION_ID_2 = "session-id-2" 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" private const val A_PASSWORD = "password"
class SessionOverviewViewModelTest { class SessionOverviewViewModelTest {
@ -81,22 +69,20 @@ class SessionOverviewViewModelTest {
) )
private val getDeviceFullInfoUseCase = mockk<GetDeviceFullInfoUseCase>(relaxed = true) private val getDeviceFullInfoUseCase = mockk<GetDeviceFullInfoUseCase>(relaxed = true)
private val fakeActiveSessionHolder = FakeActiveSessionHolder() private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val fakeStringProvider = FakeStringProvider()
private val checkIfCurrentSessionCanBeVerifiedUseCase = mockk<CheckIfCurrentSessionCanBeVerifiedUseCase>() private val checkIfCurrentSessionCanBeVerifiedUseCase = mockk<CheckIfCurrentSessionCanBeVerifiedUseCase>()
private val signoutSessionUseCase = mockk<SignoutSessionUseCase>() private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase()
private val interceptSignoutFlowResponseUseCase = mockk<InterceptSignoutFlowResponseUseCase>() private val interceptSignoutFlowResponseUseCase = mockk<InterceptSignoutFlowResponseUseCase>()
private val fakePendingAuthHandler = FakePendingAuthHandler() private val fakePendingAuthHandler = FakePendingAuthHandler()
private val refreshDevicesUseCase = mockk<RefreshDevicesUseCase>() private val refreshDevicesUseCase = mockk<RefreshDevicesUseCase>(relaxed = true)
private val togglePushNotificationUseCase = FakeTogglePushNotificationUseCase() private val togglePushNotificationUseCase = FakeTogglePushNotificationUseCase()
private val fakeGetNotificationsStatusUseCase = mockk<GetNotificationsStatusUseCase>() private val fakeGetNotificationsStatusUseCase = mockk<GetNotificationsStatusUseCase>()
private val notificationsStatus = NotificationsStatus.ENABLED private val notificationsStatus = NotificationsStatus.ENABLED
private fun createViewModel() = SessionOverviewViewModel( private fun createViewModel() = SessionOverviewViewModel(
initialState = SessionOverviewViewState(args), initialState = SessionOverviewViewState(args),
stringProvider = fakeStringProvider.instance,
getDeviceFullInfoUseCase = getDeviceFullInfoUseCase, getDeviceFullInfoUseCase = getDeviceFullInfoUseCase,
checkIfCurrentSessionCanBeVerifiedUseCase = checkIfCurrentSessionCanBeVerifiedUseCase, checkIfCurrentSessionCanBeVerifiedUseCase = checkIfCurrentSessionCanBeVerifiedUseCase,
signoutSessionUseCase = signoutSessionUseCase, signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance,
interceptSignoutFlowResponseUseCase = interceptSignoutFlowResponseUseCase, interceptSignoutFlowResponseUseCase = interceptSignoutFlowResponseUseCase,
pendingAuthHandler = fakePendingAuthHandler.instance, pendingAuthHandler = fakePendingAuthHandler.instance,
activeSessionHolder = fakeActiveSessionHolder.instance, activeSessionHolder = fakeActiveSessionHolder.instance,
@ -115,11 +101,50 @@ class SessionOverviewViewModelTest {
every { fakeGetNotificationsStatusUseCase.execute(A_SESSION_ID_1) } returns flowOf(notificationsStatus) 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 @After
fun tearDown() { fun tearDown() {
unmockkAll() 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 @Test
fun `given the viewModel has been initialized then pushers are refreshed`() { fun `given the viewModel has been initialized then pushers are refreshed`() {
createViewModel() createViewModel()
@ -223,8 +248,7 @@ class SessionOverviewViewModelTest {
val deviceFullInfo = mockk<DeviceFullInfo>() val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo) every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
givenSignoutSuccess(A_SESSION_ID_1) fakeSignoutSessionsUseCase.givenSignoutSuccess(listOf(A_SESSION_ID_1))
every { refreshDevicesUseCase.execute() } just runs
val signoutAction = SessionOverviewAction.SignoutOtherSession val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted() givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState( 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 @Test
fun `given another session and unexpected error during signout when handling signout action then signout process is performed`() { fun `given another session and unexpected error during signout when handling signout action then signout process is performed`() {
// Given // Given
@ -296,7 +285,7 @@ class SessionOverviewViewModelTest {
every { deviceFullInfo.isCurrentDevice } returns false every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo) every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val error = Exception() val error = Exception()
givenSignoutError(A_SESSION_ID_1, error) fakeSignoutSessionsUseCase.givenSignoutError(listOf(A_SESSION_ID_1), error)
val signoutAction = SessionOverviewAction.SignoutOtherSession val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted() givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState( val expectedViewState = SessionOverviewViewState(
@ -306,7 +295,6 @@ class SessionOverviewViewModelTest {
isLoading = false, isLoading = false,
notificationsStatus = notificationsStatus, notificationsStatus = notificationsStatus,
) )
fakeStringProvider.given(R.string.matrix_error, AN_ERROR_MESSAGE)
// When // When
val viewModel = createViewModel() val viewModel = createViewModel()
@ -320,7 +308,7 @@ class SessionOverviewViewModelTest {
{ copy(isLoading = true) }, { copy(isLoading = true) },
{ copy(isLoading = false) } { copy(isLoading = false) }
) )
.assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error.message == AN_ERROR_MESSAGE } .assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error == error }
.finish() .finish()
} }
@ -330,7 +318,7 @@ class SessionOverviewViewModelTest {
val deviceFullInfo = mockk<DeviceFullInfo>() val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo) 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 val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted() givenCurrentSessionIsTrusted()
val expectedPendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session) 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() { private fun givenCurrentSessionIsTrusted() {
fakeActiveSessionHolder.fakeSession.givenSessionId(A_SESSION_ID_2) fakeActiveSessionHolder.fakeSession.givenSessionId(A_SESSION_ID_2)
val deviceFullInfo = mockk<DeviceFullInfo>() val deviceFullInfo = mockk<DeviceFullInfo>()

View file

@ -24,8 +24,8 @@ import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.runs import io.mockk.runs
import io.mockk.unmockkAll import io.mockk.unmockkAll
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeInstanceOf
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -63,7 +63,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
} }
@Test @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 // Given
val registrationFlowResponse = givenNextUncompletedStage(LoginFlowTypes.PASSWORD, A_SESSION_ID) val registrationFlowResponse = givenNextUncompletedStage(LoginFlowTypes.PASSWORD, A_SESSION_ID)
fakeReAuthHelper.givenStoredPassword(A_PASSWORD) fakeReAuthHelper.givenStoredPassword(A_PASSWORD)
@ -84,7 +84,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
) )
// Then // Then
result shouldBeInstanceOf (SignoutSessionResult.Completed::class) result shouldBe null
every { every {
promise.resume(expectedAuth) promise.resume(expectedAuth)
} }
@ -97,7 +97,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
fakeReAuthHelper.givenStoredPassword(A_PASSWORD) fakeReAuthHelper.givenStoredPassword(A_PASSWORD)
val errorCode = AN_ERROR_CODE val errorCode = AN_ERROR_CODE
val promise = mockk<Continuation<UIABaseAuth>>() val promise = mockk<Continuation<UIABaseAuth>>()
val expectedResult = SignoutSessionResult.ReAuthNeeded( val expectedResult = SignoutSessionsReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = A_SESSION_ID), pendingAuth = DefaultBaseAuth(session = A_SESSION_ID),
uiaContinuation = promise, uiaContinuation = promise,
flowResponse = registrationFlowResponse, flowResponse = registrationFlowResponse,
@ -122,7 +122,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
fakeReAuthHelper.givenStoredPassword(A_PASSWORD) fakeReAuthHelper.givenStoredPassword(A_PASSWORD)
val errorCode: String? = null val errorCode: String? = null
val promise = mockk<Continuation<UIABaseAuth>>() val promise = mockk<Continuation<UIABaseAuth>>()
val expectedResult = SignoutSessionResult.ReAuthNeeded( val expectedResult = SignoutSessionsReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = A_SESSION_ID), pendingAuth = DefaultBaseAuth(session = A_SESSION_ID),
uiaContinuation = promise, uiaContinuation = promise,
flowResponse = registrationFlowResponse, flowResponse = registrationFlowResponse,
@ -147,7 +147,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
fakeReAuthHelper.givenStoredPassword(null) fakeReAuthHelper.givenStoredPassword(null)
val errorCode: String? = null val errorCode: String? = null
val promise = mockk<Continuation<UIABaseAuth>>() val promise = mockk<Continuation<UIABaseAuth>>()
val expectedResult = SignoutSessionResult.ReAuthNeeded( val expectedResult = SignoutSessionsReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = A_SESSION_ID), pendingAuth = DefaultBaseAuth(session = A_SESSION_ID),
uiaContinuation = promise, uiaContinuation = promise,
flowResponse = registrationFlowResponse, 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.mockk
import io.mockk.slot import io.mockk.slot
import org.matrix.android.sdk.api.MatrixCallback 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.CryptoService
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo 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.DeviceInfo
@ -70,16 +71,21 @@ class FakeCryptoService(
} }
} }
fun givenDeleteDeviceSucceeds(deviceId: String) { fun givenDeleteDevicesSucceeds(deviceIds: List<String>) {
val matrixCallback = slot<MatrixCallback<Unit>>() every { deleteDevices(deviceIds, any(), any()) } answers {
every { deleteDevice(deviceId, any(), capture(matrixCallback)) } answers {
thirdArg<MatrixCallback<Unit>>().onSuccess(Unit) thirdArg<MatrixCallback<Unit>>().onSuccess(Unit)
} }
} }
fun givenDeleteDeviceFailsWithError(deviceId: String, error: Exception) { fun givenDeleteDevicesNeedsUIAuth(deviceIds: List<String>) {
val matrixCallback = slot<MatrixCallback<Unit>>() every { deleteDevices(deviceIds, any(), any()) } answers {
every { deleteDevice(deviceId, any(), capture(matrixCallback)) } 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) 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)
}
}