diff --git a/changelog.d/7418.feature b/changelog.d/7418.feature
new file mode 100644
index 0000000000..b68ef700da
--- /dev/null
+++ b/changelog.d/7418.feature
@@ -0,0 +1 @@
+[Session manager] Multi-session signout
diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml
index 450eb64849..cd7cb3f477 100644
--- a/library/ui-strings/src/main/res/values/strings.xml
+++ b/library/ui-strings/src/main/res/values/strings.xml
@@ -3345,6 +3345,11 @@
No inactive sessions found.
Clear Filter
Select sessions
+ Sign out
+
+ - Sign out of %1$d session
+ - Sign out of %1$d sessions
+
Sign out of this session
Session details
Application, device, and activity information.
diff --git a/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml b/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml
index 098ec263fc..c1a51000b7 100644
--- a/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml
+++ b/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml
@@ -5,6 +5,7 @@
+
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt
index d2aa8020e8..971d04261e 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt
@@ -17,6 +17,7 @@
package org.matrix.android.sdk.api.session.crypto
import android.content.Context
+import androidx.annotation.Size
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import org.matrix.android.sdk.api.MatrixCallback
@@ -55,6 +56,8 @@ interface CryptoService {
fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback)
+ fun deleteDevices(@Size(min = 1) deviceIds: List, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback)
+
fun getCryptoVersion(context: Context, longFormat: Boolean): String
fun isCryptoEnabled(): Boolean
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt
index 9c3e0ba1c5..7862da1c17 100755
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt
@@ -242,8 +242,12 @@ internal class DefaultCryptoService @Inject constructor(
}
override fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback) {
+ deleteDevices(listOf(deviceId), userInteractiveAuthInterceptor, callback)
+ }
+
+ override fun deleteDevices(deviceIds: List, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback) {
deleteDeviceTask
- .configureWith(DeleteDeviceTask.Params(deviceId, userInteractiveAuthInterceptor, null)) {
+ .configureWith(DeleteDeviceTask.Params(deviceIds, userInteractiveAuthInterceptor, null)) {
this.executionThread = TaskThread.CRYPTO
this.callback = callback
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/api/CryptoApi.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/api/CryptoApi.kt
index d5a8bdfd7c..cfe4681bfd 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/api/CryptoApi.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/api/CryptoApi.kt
@@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.crypto.api
import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.DevicesListResponse
import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams
+import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDevicesParams
import org.matrix.android.sdk.internal.crypto.model.rest.KeyChangesResponse
import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimBody
import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimResponse
@@ -136,6 +137,17 @@ internal interface CryptoApi {
@Body params: DeleteDeviceParams
)
+ /**
+ * Deletes the given devices, and invalidates any access token associated with them.
+ * Doc: https://spec.matrix.org/v1.4/client-server-api/#post_matrixclientv3delete_devices
+ *
+ * @param params the deletion parameters
+ */
+ @POST(NetworkConstants.URI_API_PREFIX_PATH_V3 + "delete_devices")
+ suspend fun deleteDevices(
+ @Body params: DeleteDevicesParams
+ )
+
/**
* Update the device information.
* Doc: https://matrix.org/docs/spec/client_server/r0.4.0.html#put-matrix-client-r0-devices-deviceid
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDeviceParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDeviceParams.kt
index c26c6107c4..24dccc4d90 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDeviceParams.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDeviceParams.kt
@@ -23,6 +23,9 @@ import com.squareup.moshi.JsonClass
*/
@JsonClass(generateAdapter = true)
internal data class DeleteDeviceParams(
+ /**
+ * Additional authentication information for the user-interactive authentication API.
+ */
@Json(name = "auth")
- val auth: Map? = null
+ val auth: Map? = null,
)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDevicesParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDevicesParams.kt
new file mode 100644
index 0000000000..19b33b2a69
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDevicesParams.kt
@@ -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? = null,
+
+ /**
+ * Required: The list of device IDs to delete.
+ */
+ @Json(name = "devices")
+ val deviceIds: List,
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt
index 0a77d33acc..549122447e 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt
@@ -16,12 +16,14 @@
package org.matrix.android.sdk.internal.crypto.tasks
+import androidx.annotation.Size
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.session.uia.UiaResult
import org.matrix.android.sdk.internal.auth.registration.handleUIA
import org.matrix.android.sdk.internal.crypto.api.CryptoApi
import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams
+import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDevicesParams
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.task.Task
@@ -30,7 +32,7 @@ import javax.inject.Inject
internal interface DeleteDeviceTask : Task {
data class Params(
- val deviceId: String,
+ @Size(min = 1) val deviceIds: List,
val userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor?,
val userAuthParam: UIABaseAuth?
)
@@ -42,9 +44,24 @@ internal class DefaultDeleteDeviceTask @Inject constructor(
) : DeleteDeviceTask {
override suspend fun execute(params: DeleteDeviceTask.Params) {
+ require(params.deviceIds.isNotEmpty())
+
try {
executeRequest(globalErrorReceiver) {
- cryptoApi.deleteDevice(params.deviceId, DeleteDeviceParams(params.userAuthParam?.asMap()))
+ val userAuthParam = params.userAuthParam?.asMap()
+ if (params.deviceIds.size == 1) {
+ cryptoApi.deleteDevice(
+ deviceId = params.deviceIds.first(),
+ DeleteDeviceParams(auth = userAuthParam)
+ )
+ } else {
+ cryptoApi.deleteDevices(
+ DeleteDevicesParams(
+ auth = userAuthParam,
+ deviceIds = params.deviceIds
+ )
+ )
+ }
}
} catch (throwable: Throwable) {
if (params.userInteractiveAuthInterceptor == null ||
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt
index 5aec7db66c..4bfda0bf3c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt
@@ -22,6 +22,7 @@ internal object NetworkConstants {
const val URI_API_PREFIX_PATH_ = "$URI_API_PREFIX_PATH/"
const val URI_API_PREFIX_PATH_R0 = "$URI_API_PREFIX_PATH/r0/"
const val URI_API_PREFIX_PATH_V1 = "$URI_API_PREFIX_PATH/v1/"
+ const val URI_API_PREFIX_PATH_V3 = "$URI_API_PREFIX_PATH/v3/"
const val URI_API_PREFIX_PATH_UNSTABLE = "$URI_API_PREFIX_PATH/unstable/"
// Media
diff --git a/vector/src/main/java/im/vector/app/core/extensions/MenuItemExt.kt b/vector/src/main/java/im/vector/app/core/extensions/MenuItemExt.kt
new file mode 100644
index 0000000000..7d62a0c357
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/core/extensions/MenuItemExt.kt
@@ -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)
+}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt
index c7437db44c..21cbb86e94 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt
@@ -20,6 +20,13 @@ import im.vector.app.core.platform.VectorViewModelAction
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
sealed class DevicesAction : VectorViewModelAction {
+ // ReAuth
+ object SsoAuthDone : DevicesAction()
+ data class PasswordAuthDone(val password: String) : DevicesAction()
+ object ReAuthCancelled : DevicesAction()
+
+ // Others
object VerifyCurrentSession : DevicesAction()
data class MarkAsManuallyVerified(val cryptoDeviceInfo: CryptoDeviceInfo) : DevicesAction()
+ object MultiSignoutOtherSessions : DevicesAction()
}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewEvent.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewEvent.kt
index c78c20f792..9f5257693e 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewEvent.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewEvent.kt
@@ -19,15 +19,17 @@ package im.vector.app.features.settings.devices.v2
import im.vector.app.core.platform.VectorViewEvents
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
-import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
sealed class DevicesViewEvent : VectorViewEvents {
- data class Loading(val message: CharSequence? = null) : DevicesViewEvent()
- data class Failure(val throwable: Throwable) : DevicesViewEvent()
- data class RequestReAuth(val registrationFlowResponse: RegistrationFlowResponse, val lastErrorCode: String?) : DevicesViewEvent()
- data class PromptRenameDevice(val deviceInfo: DeviceInfo) : DevicesViewEvent()
+ data class RequestReAuth(
+ val registrationFlowResponse: RegistrationFlowResponse,
+ val lastErrorCode: String?
+ ) : DevicesViewEvent()
+
data class ShowVerifyDevice(val userId: String, val transactionId: String?) : DevicesViewEvent()
object SelfVerification : DevicesViewEvent()
data class ShowManuallyVerify(val cryptoDeviceInfo: CryptoDeviceInfo) : DevicesViewEvent()
object PromptResetSecrets : DevicesViewEvent()
+ object SignoutSuccess : DevicesViewEvent()
+ data class SignoutError(val error: Throwable) : DevicesViewEvent()
}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt
index a5405756eb..cd97795b69 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt
@@ -24,13 +24,19 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
+import im.vector.app.features.auth.PendingAuthHandler
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType
+import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase
+import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded
+import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase
import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.orFalse
+import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
+import timber.log.Timber
class DevicesViewModel @AssistedInject constructor(
@Assisted initialState: DevicesViewState,
@@ -39,6 +45,9 @@ class DevicesViewModel @AssistedInject constructor(
private val getDeviceFullInfoListUseCase: GetDeviceFullInfoListUseCase,
private val refreshDevicesOnCryptoDevicesChangeUseCase: RefreshDevicesOnCryptoDevicesChangeUseCase,
private val checkIfCurrentSessionCanBeVerifiedUseCase: CheckIfCurrentSessionCanBeVerifiedUseCase,
+ private val signoutSessionsUseCase: SignoutSessionsUseCase,
+ private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase,
+ private val pendingAuthHandler: PendingAuthHandler,
refreshDevicesUseCase: RefreshDevicesUseCase,
) : VectorSessionsListViewModel(initialState, activeSessionHolder, refreshDevicesUseCase) {
@@ -97,8 +106,12 @@ class DevicesViewModel @AssistedInject constructor(
override fun handle(action: DevicesAction) {
when (action) {
+ is DevicesAction.PasswordAuthDone -> handlePasswordAuthDone(action)
+ DevicesAction.ReAuthCancelled -> handleReAuthCancelled()
+ DevicesAction.SsoAuthDone -> handleSsoAuthDone()
is DevicesAction.VerifyCurrentSession -> handleVerifyCurrentSessionAction()
is DevicesAction.MarkAsManuallyVerified -> handleMarkAsManuallyVerifiedAction()
+ DevicesAction.MultiSignoutOtherSessions -> handleMultiSignoutOtherSessions()
}
}
@@ -116,4 +129,66 @@ class DevicesViewModel @AssistedInject constructor(
private fun handleMarkAsManuallyVerifiedAction() {
// TODO implement when needed
}
+
+ private fun handleMultiSignoutOtherSessions() = withState { state ->
+ viewModelScope.launch {
+ setLoading(true)
+ val deviceIds = getDeviceIdsOfOtherSessions(state)
+ if (deviceIds.isEmpty()) {
+ return@launch
+ }
+ val result = signout(deviceIds)
+ setLoading(false)
+
+ val error = result.exceptionOrNull()
+ if (error == null) {
+ onSignoutSuccess()
+ } else {
+ onSignoutFailure(error)
+ }
+ }
+ }
+
+ private fun getDeviceIdsOfOtherSessions(state: DevicesViewState): List {
+ val currentDeviceId = state.currentSessionCrossSigningInfo.deviceId
+ return state.devices()
+ ?.mapNotNull { fullInfo -> fullInfo.deviceInfo.deviceId.takeUnless { it == currentDeviceId } }
+ .orEmpty()
+ }
+
+ private suspend fun signout(deviceIds: List) = 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()
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt
index 1c348af4f9..3a3c3463fb 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt
@@ -16,6 +16,7 @@
package im.vector.app.features.settings.devices.v2
+import android.app.Activity
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
@@ -30,12 +31,15 @@ import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.dialogs.ManuallyVerifyDialog
+import im.vector.app.core.extensions.registerStartForActivityResult
+import im.vector.app.core.extensions.setTextColor
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.DrawableProvider
import im.vector.app.core.resources.StringProvider
import im.vector.app.databinding.FragmentSettingsDevicesBinding
import im.vector.app.features.VectorFeatures
+import im.vector.app.features.auth.ReAuthActivity
import im.vector.app.features.crypto.recover.SetupMode
import im.vector.app.features.crypto.verification.VerificationBottomSheet
import im.vector.app.features.login.qr.QrCodeLoginArgs
@@ -47,6 +51,8 @@ import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INAC
import im.vector.app.features.settings.devices.v2.list.SecurityRecommendationView
import im.vector.app.features.settings.devices.v2.list.SecurityRecommendationViewState
import im.vector.app.features.settings.devices.v2.list.SessionInfoViewState
+import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase
+import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import javax.inject.Inject
@@ -70,6 +76,8 @@ class VectorSettingsDevicesFragment :
@Inject lateinit var stringProvider: StringProvider
+ @Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase
+
private val viewModel: DevicesViewModel by fragmentViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSettingsDevicesBinding {
@@ -91,6 +99,7 @@ class VectorSettingsDevicesFragment :
super.onViewCreated(view, savedInstanceState)
initWaitingView()
+ initOtherSessionsHeaderView()
initOtherSessionsView()
initSecurityRecommendationsView()
initQrLoginView()
@@ -100,10 +109,7 @@ class VectorSettingsDevicesFragment :
private fun observeViewEvents() {
viewModel.observeViewEvents {
when (it) {
- is DevicesViewEvent.Loading -> showLoading(it.message)
- is DevicesViewEvent.Failure -> showFailure(it.throwable)
- is DevicesViewEvent.RequestReAuth -> Unit // TODO. Next PR
- is DevicesViewEvent.PromptRenameDevice -> Unit // TODO. Next PR
+ is DevicesViewEvent.RequestReAuth -> askForReAuthentication(it)
is DevicesViewEvent.ShowVerifyDevice -> {
VerificationBottomSheet.withArgs(
roomId = null,
@@ -122,6 +128,8 @@ class VectorSettingsDevicesFragment :
is DevicesViewEvent.PromptResetSecrets -> {
navigator.open4SSetup(requireActivity(), SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET)
}
+ is DevicesViewEvent.SignoutError -> showFailure(it.error)
+ is DevicesViewEvent.SignoutSuccess -> Unit // do nothing
}
}
}
@@ -131,6 +139,29 @@ class VectorSettingsDevicesFragment :
views.waitingView.waitingStatusText.isVisible = true
}
+ private fun initOtherSessionsHeaderView() {
+ views.deviceListHeaderOtherSessions.setOnMenuItemClickListener { menuItem ->
+ when (menuItem.itemId) {
+ R.id.otherSessionsHeaderMultiSignout -> {
+ confirmMultiSignoutOtherSessions()
+ true
+ }
+ else -> false
+ }
+ }
+ }
+
+ private fun confirmMultiSignoutOtherSessions() {
+ activity?.let {
+ buildConfirmSignoutDialogUseCase.execute(it, this::multiSignoutOtherSessions)
+ .show()
+ }
+ }
+
+ private fun multiSignoutOtherSessions() {
+ viewModel.handle(DevicesAction.MultiSignoutOtherSessions)
+ }
+
private fun initOtherSessionsView() {
views.deviceListOtherSessions.callback = this
}
@@ -142,7 +173,7 @@ class VectorSettingsDevicesFragment :
requireActivity(),
R.string.device_manager_header_section_security_recommendations_title,
DeviceManagerFilterType.UNVERIFIED,
- excludeCurrentDevice = false
+ excludeCurrentDevice = true
)
}
}
@@ -152,7 +183,7 @@ class VectorSettingsDevicesFragment :
requireActivity(),
R.string.device_manager_header_section_security_recommendations_title,
DeviceManagerFilterType.INACTIVE,
- excludeCurrentDevice = false
+ excludeCurrentDevice = true
)
}
}
@@ -271,6 +302,11 @@ class VectorSettingsDevicesFragment :
hideOtherSessionsView()
} else {
views.deviceListHeaderOtherSessions.isVisible = true
+ val color = colorProvider.getColorFromAttribute(R.attr.colorError)
+ val multiSignoutItem = views.deviceListHeaderOtherSessions.menu.findItem(R.id.otherSessionsHeaderMultiSignout)
+ val nbDevices = otherDevices.size
+ multiSignoutItem.title = stringProvider.getQuantityString(R.plurals.device_manager_other_sessions_multi_signout_all, nbDevices, nbDevices)
+ multiSignoutItem.setTextColor(color)
views.deviceListOtherSessions.isVisible = true
views.deviceListOtherSessions.render(
devices = otherDevices.take(NUMBER_OF_OTHER_DEVICES_TO_RENDER),
@@ -347,4 +383,37 @@ class VectorSettingsDevicesFragment :
excludeCurrentDevice = true
)
}
+
+ private val reAuthActivityResultLauncher = registerStartForActivityResult { activityResult ->
+ if (activityResult.resultCode == Activity.RESULT_OK) {
+ when (activityResult.data?.extras?.getString(ReAuthActivity.RESULT_FLOW_TYPE)) {
+ LoginFlowTypes.SSO -> {
+ viewModel.handle(DevicesAction.SsoAuthDone)
+ }
+ LoginFlowTypes.PASSWORD -> {
+ val password = activityResult.data?.extras?.getString(ReAuthActivity.RESULT_VALUE) ?: ""
+ viewModel.handle(DevicesAction.PasswordAuthDone(password))
+ }
+ else -> {
+ viewModel.handle(DevicesAction.ReAuthCancelled)
+ }
+ }
+ } else {
+ viewModel.handle(DevicesAction.ReAuthCancelled)
+ }
+ }
+
+ /**
+ * Launch the re auth activity to get credentials.
+ */
+ private fun askForReAuthentication(reAuthReq: DevicesViewEvent.RequestReAuth) {
+ ReAuthActivity.newIntent(
+ requireContext(),
+ reAuthReq.registrationFlowResponse,
+ reAuthReq.lastErrorCode,
+ getString(R.string.devices_delete_dialog_title)
+ ).let { intent ->
+ reAuthActivityResultLauncher.launch(intent)
+ }
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt
index 0660e7d642..f74d88790c 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt
@@ -20,6 +20,10 @@ import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import androidx.appcompat.view.menu.MenuBuilder
+import androidx.appcompat.widget.ActionMenuView.OnMenuItemClickListener
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.use
import androidx.core.view.isVisible
@@ -39,6 +43,7 @@ class SessionsListHeaderView @JvmOverloads constructor(
this
)
+ val menu: Menu = binding.sessionsListHeaderMenu.menu
var onLearnMoreClickListener: (() -> Unit)? = null
init {
@@ -50,6 +55,7 @@ class SessionsListHeaderView @JvmOverloads constructor(
).use {
setTitle(it)
setDescription(it)
+ setMenu(it)
}
}
@@ -90,4 +96,19 @@ class SessionsListHeaderView @JvmOverloads constructor(
onLearnMoreClickListener?.invoke()
}
}
+
+ private fun setMenu(typedArray: TypedArray) {
+ val menuResId = typedArray.getResourceId(R.styleable.SessionsListHeaderView_sessionsListHeaderMenu, -1)
+ if (menuResId == -1) {
+ binding.sessionsListHeaderMenu.isVisible = false
+ } else {
+ binding.sessionsListHeaderMenu.showOverflowMenu()
+ val menuBuilder = binding.sessionsListHeaderMenu.menu as? MenuBuilder
+ menuBuilder?.let { MenuInflater(context).inflate(menuResId, it) }
+ }
+ }
+
+ fun setOnMenuItemClickListener(listener: OnMenuItemClickListener) {
+ binding.sessionsListHeaderMenu.setOnMenuItemClickListener(listener)
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt
index 1978708ebf..24d2a08bdc 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt
@@ -20,10 +20,17 @@ import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType
sealed class OtherSessionsAction : VectorViewModelAction {
+ // ReAuth
+ object SsoAuthDone : OtherSessionsAction()
+ data class PasswordAuthDone(val password: String) : OtherSessionsAction()
+ object ReAuthCancelled : OtherSessionsAction()
+
+ // Others
data class FilterDevices(val filterType: DeviceManagerFilterType) : OtherSessionsAction()
data class EnableSelectMode(val deviceId: String?) : OtherSessionsAction()
object DisableSelectMode : OtherSessionsAction()
data class ToggleSelectionForDevice(val deviceId: String) : OtherSessionsAction()
object SelectAll : OtherSessionsAction()
object DeselectAll : OtherSessionsAction()
+ object MultiSignout : OtherSessionsAction()
}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt
index 4f1c8353f5..487531646a 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt
@@ -16,6 +16,7 @@
package im.vector.app.features.settings.devices.v2.othersessions
+import android.app.Activity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
@@ -32,6 +33,8 @@ import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
+import im.vector.app.core.extensions.registerStartForActivityResult
+import im.vector.app.core.extensions.setTextColor
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment.ResultListener.Companion.RESULT_OK
import im.vector.app.core.platform.VectorBaseFragment
@@ -39,13 +42,16 @@ import im.vector.app.core.platform.VectorMenuProvider
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.StringProvider
import im.vector.app.databinding.FragmentOtherSessionsBinding
+import im.vector.app.features.auth.ReAuthActivity
import im.vector.app.features.settings.devices.v2.DeviceFullInfo
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterBottomSheet
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType
import im.vector.app.features.settings.devices.v2.list.OtherSessionsView
import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS
import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet
+import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase
import im.vector.app.features.themes.ThemeUtils
+import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.extensions.orFalse
import javax.inject.Inject
@@ -65,6 +71,8 @@ class OtherSessionsFragment :
@Inject lateinit var viewNavigator: OtherSessionsViewNavigator
+ @Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase
+
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentOtherSessionsBinding {
return FragmentOtherSessionsBinding.inflate(layoutInflater, container, false)
}
@@ -77,9 +85,33 @@ class OtherSessionsFragment :
menu.findItem(R.id.otherSessionsSelectAll).isVisible = isSelectModeEnabled
menu.findItem(R.id.otherSessionsDeselectAll).isVisible = isSelectModeEnabled
menu.findItem(R.id.otherSessionsSelect).isVisible = !isSelectModeEnabled && state.devices()?.isNotEmpty().orFalse()
+ updateMultiSignoutMenuItem(menu, state)
}
}
+ private fun updateMultiSignoutMenuItem(menu: Menu, viewState: OtherSessionsViewState) {
+ val multiSignoutItem = menu.findItem(R.id.otherSessionsMultiSignout)
+ multiSignoutItem.title = if (viewState.isSelectModeEnabled) {
+ getString(R.string.device_manager_other_sessions_multi_signout_selection).uppercase()
+ } else {
+ val nbDevices = viewState.devices()?.size ?: 0
+ stringProvider.getQuantityString(R.plurals.device_manager_other_sessions_multi_signout_all, nbDevices, nbDevices)
+ }
+ multiSignoutItem.isVisible = if (viewState.isSelectModeEnabled) {
+ viewState.devices.invoke()?.any { it.isSelected }.orFalse()
+ } else {
+ viewState.devices.invoke()?.isNotEmpty().orFalse()
+ }
+ val showAsActionFlag = if (viewState.isSelectModeEnabled) MenuItem.SHOW_AS_ACTION_IF_ROOM else MenuItem.SHOW_AS_ACTION_NEVER
+ multiSignoutItem.setShowAsAction(showAsActionFlag or MenuItem.SHOW_AS_ACTION_WITH_TEXT)
+ changeTextColorOfDestructiveAction(multiSignoutItem)
+ }
+
+ private fun changeTextColorOfDestructiveAction(menuItem: MenuItem) {
+ val titleColor = colorProvider.getColorFromAttribute(R.attr.colorError)
+ menuItem.setTextColor(titleColor)
+ }
+
override fun handleMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.otherSessionsSelect -> {
@@ -94,10 +126,25 @@ class OtherSessionsFragment :
viewModel.handle(OtherSessionsAction.DeselectAll)
true
}
+ R.id.otherSessionsMultiSignout -> {
+ confirmMultiSignout()
+ true
+ }
else -> false
}
}
+ private fun confirmMultiSignout() {
+ activity?.let {
+ buildConfirmSignoutDialogUseCase.execute(it, this::multiSignout)
+ .show()
+ }
+ }
+
+ private fun multiSignout() {
+ viewModel.handle(OtherSessionsAction.MultiSignout)
+ }
+
private fun enableSelectMode(isEnabled: Boolean, deviceId: String? = null) {
val action = if (isEnabled) OtherSessionsAction.EnableSelectMode(deviceId) else OtherSessionsAction.DisableSelectMode
viewModel.handle(action)
@@ -129,8 +176,9 @@ class OtherSessionsFragment :
private fun observeViewEvents() {
viewModel.observeViewEvents {
when (it) {
- is OtherSessionsViewEvents.Loading -> showLoading(it.message)
- is OtherSessionsViewEvents.Failure -> showFailure(it.throwable)
+ is OtherSessionsViewEvents.SignoutError -> showFailure(it.error)
+ is OtherSessionsViewEvents.RequestReAuth -> askForReAuthentication(it)
+ OtherSessionsViewEvents.SignoutSuccess -> enableSelectMode(false)
}
}
}
@@ -162,6 +210,7 @@ class OtherSessionsFragment :
}
override fun invalidate() = withState(viewModel) { state ->
+ updateLoading(state.isLoading)
if (state.devices is Success) {
val devices = state.devices.invoke()
renderDevices(devices, state.currentFilter)
@@ -169,6 +218,14 @@ class OtherSessionsFragment :
}
}
+ private fun updateLoading(isLoading: Boolean) {
+ if (isLoading) {
+ showLoading(null)
+ } else {
+ dismissLoadingDialog()
+ }
+ }
+
private fun updateToolbar(devices: List, isSelectModeEnabled: Boolean) {
invalidateOptionsMenu()
val title = if (isSelectModeEnabled) {
@@ -283,4 +340,37 @@ class OtherSessionsFragment :
override fun onViewAllOtherSessionsClicked() {
// NOOP. We don't have this button in this screen
}
+
+ private val reAuthActivityResultLauncher = registerStartForActivityResult { activityResult ->
+ if (activityResult.resultCode == Activity.RESULT_OK) {
+ when (activityResult.data?.extras?.getString(ReAuthActivity.RESULT_FLOW_TYPE)) {
+ LoginFlowTypes.SSO -> {
+ viewModel.handle(OtherSessionsAction.SsoAuthDone)
+ }
+ LoginFlowTypes.PASSWORD -> {
+ val password = activityResult.data?.extras?.getString(ReAuthActivity.RESULT_VALUE) ?: ""
+ viewModel.handle(OtherSessionsAction.PasswordAuthDone(password))
+ }
+ else -> {
+ viewModel.handle(OtherSessionsAction.ReAuthCancelled)
+ }
+ }
+ } else {
+ viewModel.handle(OtherSessionsAction.ReAuthCancelled)
+ }
+ }
+
+ /**
+ * Launch the re auth activity to get credentials.
+ */
+ private fun askForReAuthentication(reAuthReq: OtherSessionsViewEvents.RequestReAuth) {
+ ReAuthActivity.newIntent(
+ requireContext(),
+ reAuthReq.registrationFlowResponse,
+ reAuthReq.lastErrorCode,
+ getString(R.string.devices_delete_dialog_title)
+ ).let { intent ->
+ reAuthActivityResultLauncher.launch(intent)
+ }
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewEvents.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewEvents.kt
index 95f9c72b33..55753e35be 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewEvents.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewEvents.kt
@@ -17,8 +17,14 @@
package im.vector.app.features.settings.devices.v2.othersessions
import im.vector.app.core.platform.VectorViewEvents
+import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
sealed class OtherSessionsViewEvents : VectorViewEvents {
- data class Loading(val message: CharSequence? = null) : OtherSessionsViewEvents()
- data class Failure(val throwable: Throwable) : OtherSessionsViewEvents()
+ data class RequestReAuth(
+ val registrationFlowResponse: RegistrationFlowResponse,
+ val lastErrorCode: String?
+ ) : OtherSessionsViewEvents()
+
+ object SignoutSuccess : OtherSessionsViewEvents()
+ data class SignoutError(val error: Throwable) : OtherSessionsViewEvents()
}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt
index 2cd0c6af66..9b4c26ee4f 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt
@@ -24,16 +24,24 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
+import im.vector.app.features.auth.PendingAuthHandler
import im.vector.app.features.settings.devices.v2.GetDeviceFullInfoListUseCase
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase
import im.vector.app.features.settings.devices.v2.VectorSessionsListViewModel
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType
+import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded
+import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase
import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
+import timber.log.Timber
class OtherSessionsViewModel @AssistedInject constructor(
@Assisted private val initialState: OtherSessionsViewState,
activeSessionHolder: ActiveSessionHolder,
private val getDeviceFullInfoListUseCase: GetDeviceFullInfoListUseCase,
+ private val signoutSessionsUseCase: SignoutSessionsUseCase,
+ private val pendingAuthHandler: PendingAuthHandler,
refreshDevicesUseCase: RefreshDevicesUseCase
) : VectorSessionsListViewModel(
initialState, activeSessionHolder, refreshDevicesUseCase
@@ -67,12 +75,16 @@ class OtherSessionsViewModel @AssistedInject constructor(
override fun handle(action: OtherSessionsAction) {
when (action) {
+ is OtherSessionsAction.PasswordAuthDone -> handlePasswordAuthDone(action)
+ OtherSessionsAction.ReAuthCancelled -> handleReAuthCancelled()
+ OtherSessionsAction.SsoAuthDone -> handleSsoAuthDone()
is OtherSessionsAction.FilterDevices -> handleFilterDevices(action)
OtherSessionsAction.DisableSelectMode -> handleDisableSelectMode()
is OtherSessionsAction.EnableSelectMode -> handleEnableSelectMode(action.deviceId)
is OtherSessionsAction.ToggleSelectionForDevice -> handleToggleSelectionForDevice(action.deviceId)
OtherSessionsAction.DeselectAll -> handleDeselectAll()
OtherSessionsAction.SelectAll -> handleSelectAll()
+ OtherSessionsAction.MultiSignout -> handleMultiSignout()
}
}
@@ -142,4 +154,67 @@ class OtherSessionsViewModel @AssistedInject constructor(
)
}
}
+
+ private fun handleMultiSignout() = withState { state ->
+ viewModelScope.launch {
+ setLoading(true)
+ val deviceIds = getDeviceIdsToSignout(state)
+ if (deviceIds.isEmpty()) {
+ return@launch
+ }
+ val result = signout(deviceIds)
+ setLoading(false)
+
+ val error = result.exceptionOrNull()
+ if (error == null) {
+ onSignoutSuccess()
+ } else {
+ onSignoutFailure(error)
+ }
+ }
+ }
+
+ private fun getDeviceIdsToSignout(state: OtherSessionsViewState): List {
+ return if (state.isSelectModeEnabled) {
+ state.devices()?.filter { it.isSelected }.orEmpty()
+ } else {
+ state.devices().orEmpty()
+ }.mapNotNull { it.deviceInfo.deviceId }
+ }
+
+ private suspend fun signout(deviceIds: List) = 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()
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt
index 0db3c8cd0e..c0b50fded8 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt
@@ -27,6 +27,7 @@ data class OtherSessionsViewState(
val currentFilter: DeviceManagerFilterType = DeviceManagerFilterType.ALL_SESSIONS,
val excludeCurrentDevice: Boolean = false,
val isSelectModeEnabled: Boolean = false,
+ val isLoading: Boolean = false,
) : MavericksState {
constructor(args: OtherSessionsArgs) : this(excludeCurrentDevice = args.excludeCurrentDevice)
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt
index 620372f810..e149023f22 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt
@@ -29,7 +29,6 @@ import androidx.core.view.isVisible
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.date.VectorDateFormatter
@@ -45,6 +44,7 @@ import im.vector.app.features.crypto.recover.SetupMode
import im.vector.app.features.settings.devices.v2.list.SessionInfoViewState
import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet
import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus
+import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase
import im.vector.app.features.workers.signout.SignOutUiWorker
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.extensions.orFalse
@@ -69,6 +69,8 @@ class SessionOverviewFragment :
@Inject lateinit var stringProvider: StringProvider
+ @Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase
+
private val viewModel: SessionOverviewViewModel by fragmentViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSessionOverviewBinding {
@@ -134,13 +136,7 @@ class SessionOverviewFragment :
private fun confirmSignoutOtherSession() {
activity?.let {
- MaterialAlertDialogBuilder(it)
- .setTitle(R.string.action_sign_out)
- .setMessage(R.string.action_sign_out_confirmation_simple)
- .setPositiveButton(R.string.action_sign_out) { _, _ ->
- signoutSession()
- }
- .setNegativeButton(R.string.action_cancel, null)
+ buildConfirmSignoutDialogUseCase.execute(it, this::signoutSession)
.show()
}
}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt
index e6aa7c2747..9c4ece7e02 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt
@@ -21,42 +21,33 @@ import com.airbnb.mvrx.Success
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
-import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
-import im.vector.app.core.resources.StringProvider
import im.vector.app.features.auth.PendingAuthHandler
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase
import im.vector.app.features.settings.devices.v2.VectorSessionsListViewModel
import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase
import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase
import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase
-import im.vector.app.features.settings.devices.v2.signout.SignoutSessionResult
-import im.vector.app.features.settings.devices.v2.signout.SignoutSessionUseCase
+import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded
+import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase
import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
-import org.matrix.android.sdk.api.auth.UIABaseAuth
-import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
-import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.extensions.orFalse
-import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
import timber.log.Timber
-import javax.net.ssl.HttpsURLConnection
-import kotlin.coroutines.Continuation
class SessionOverviewViewModel @AssistedInject constructor(
@Assisted val initialState: SessionOverviewViewState,
- private val stringProvider: StringProvider,
private val getDeviceFullInfoUseCase: GetDeviceFullInfoUseCase,
private val checkIfCurrentSessionCanBeVerifiedUseCase: CheckIfCurrentSessionCanBeVerifiedUseCase,
- private val signoutSessionUseCase: SignoutSessionUseCase,
+ private val signoutSessionsUseCase: SignoutSessionsUseCase,
private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase,
private val pendingAuthHandler: PendingAuthHandler,
private val activeSessionHolder: ActiveSessionHolder,
@@ -154,30 +145,21 @@ class SessionOverviewViewModel @AssistedInject constructor(
private fun handleSignoutOtherSession(deviceId: String) {
viewModelScope.launch {
setLoading(true)
- val signoutResult = signout(deviceId)
+ val result = signout(deviceId)
setLoading(false)
- if (signoutResult.isSuccess) {
+ val error = result.exceptionOrNull()
+ if (error == null) {
onSignoutSuccess()
} else {
- when (val failure = signoutResult.exceptionOrNull()) {
- null -> onSignoutSuccess()
- else -> onSignoutFailure(failure)
- }
+ onSignoutFailure(error)
}
}
}
- private suspend fun signout(deviceId: String) = signoutSessionUseCase.execute(deviceId, object : UserInteractiveAuthInterceptor {
- override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) {
- when (val result = interceptSignoutFlowResponseUseCase.execute(flowResponse, errCode, promise)) {
- is SignoutSessionResult.ReAuthNeeded -> onReAuthNeeded(result)
- is SignoutSessionResult.Completed -> Unit
- }
- }
- })
+ private suspend fun signout(deviceId: String) = signoutSessionsUseCase.execute(listOf(deviceId), this::onReAuthNeeded)
- private fun onReAuthNeeded(reAuthNeeded: SignoutSessionResult.ReAuthNeeded) {
+ private fun onReAuthNeeded(reAuthNeeded: SignoutSessionsReAuthNeeded) {
Timber.d("onReAuthNeeded")
pendingAuthHandler.pendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session)
pendingAuthHandler.uiaContinuation = reAuthNeeded.uiaContinuation
@@ -196,12 +178,7 @@ class SessionOverviewViewModel @AssistedInject constructor(
private fun onSignoutFailure(failure: Throwable) {
Timber.e("signout failure", failure)
- val failureMessage = if (failure is Failure.OtherServerError && failure.httpCode == HttpsURLConnection.HTTP_UNAUTHORIZED) {
- stringProvider.getString(R.string.authentication_error)
- } else {
- stringProvider.getString(R.string.matrix_error)
- }
- _viewEvents.post(SessionOverviewViewEvent.SignoutError(Exception(failureMessage)))
+ _viewEvents.post(SessionOverviewViewEvent.SignoutError(failure))
}
private fun handleSsoAuthDone() {
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/BuildConfirmSignoutDialogUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/BuildConfirmSignoutDialogUseCase.kt
new file mode 100644
index 0000000000..4edfc2febe
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/BuildConfirmSignoutDialogUseCase.kt
@@ -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()
+}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCase.kt
index 4316995272..42ebd7782e 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCase.kt
@@ -37,17 +37,16 @@ class InterceptSignoutFlowResponseUseCase @Inject constructor(
flowResponse: RegistrationFlowResponse,
errCode: String?,
promise: Continuation
- ): SignoutSessionResult {
+ ): SignoutSessionsReAuthNeeded? {
return if (flowResponse.nextUncompletedStage() == LoginFlowTypes.PASSWORD && reAuthHelper.data != null && errCode == null) {
UserPasswordAuth(
session = null,
user = activeSessionHolder.getActiveSession().myUserId,
password = reAuthHelper.data
).let { promise.resume(it) }
-
- SignoutSessionResult.Completed
+ null
} else {
- SignoutSessionResult.ReAuthNeeded(
+ SignoutSessionsReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = flowResponse.session),
uiaContinuation = promise,
flowResponse = flowResponse,
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCase.kt
deleted file mode 100644
index 60ca8e91c6..0000000000
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCase.kt
+++ /dev/null
@@ -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 {
- return deleteDevice(deviceId, userInteractiveAuthInterceptor)
- }
-
- private suspend fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor) = runCatching {
- awaitCallback { matrixCallback ->
- activeSessionHolder.getActiveSession()
- .cryptoService()
- .deleteDevice(deviceId, userInteractiveAuthInterceptor, matrixCallback)
- }
- }
-}
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionResult.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsReAuthNeeded.kt
similarity index 71%
rename from vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionResult.kt
rename to vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsReAuthNeeded.kt
index fa1fb31b66..56e3d17686 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionResult.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsReAuthNeeded.kt
@@ -20,13 +20,9 @@ import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import kotlin.coroutines.Continuation
-sealed class SignoutSessionResult {
- data class ReAuthNeeded(
- val pendingAuth: UIABaseAuth,
- val uiaContinuation: Continuation,
- val flowResponse: RegistrationFlowResponse,
- val errCode: String?
- ) : SignoutSessionResult()
-
- object Completed : SignoutSessionResult()
-}
+data class SignoutSessionsReAuthNeeded(
+ val pendingAuth: UIABaseAuth,
+ val uiaContinuation: Continuation,
+ val flowResponse: RegistrationFlowResponse,
+ val errCode: String?
+)
diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCase.kt
new file mode 100644
index 0000000000..1cf713a711
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCase.kt
@@ -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,
+ onReAuthNeeded: (SignoutSessionsReAuthNeeded) -> Unit,
+ ): Result = runCatching {
+ Timber.d("start execute with ${deviceIds.size} deviceIds")
+
+ val authInterceptor = object : UserInteractiveAuthInterceptor {
+ override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) {
+ val result = interceptSignoutFlowResponseUseCase.execute(flowResponse, errCode, promise)
+ result?.let(onReAuthNeeded)
+ }
+ }
+
+ deleteDevices(deviceIds, authInterceptor)
+ Timber.d("end execute")
+ }
+
+ private suspend fun deleteDevices(deviceIds: List, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor) =
+ awaitCallback { matrixCallback ->
+ activeSessionHolder.getActiveSession()
+ .cryptoService()
+ .deleteDevices(deviceIds, userInteractiveAuthInterceptor, matrixCallback)
+ }
+}
diff --git a/vector/src/main/res/layout/fragment_settings_devices.xml b/vector/src/main/res/layout/fragment_settings_devices.xml
index 38137b2029..8134774887 100644
--- a/vector/src/main/res/layout/fragment_settings_devices.xml
+++ b/vector/src/main/res/layout/fragment_settings_devices.xml
@@ -98,6 +98,7 @@
app:layout_constraintTop_toBottomOf="@id/deviceListDividerCurrentSession"
app:sessionsListHeaderDescription="@string/device_manager_sessions_other_description"
app:sessionsListHeaderHasLearnMoreLink="false"
+ app:sessionsListHeaderMenu="@menu/menu_other_sessions_header"
app:sessionsListHeaderTitle="@string/device_manager_sessions_other_title" />
diff --git a/vector/src/main/res/layout/view_sessions_list_header.xml b/vector/src/main/res/layout/view_sessions_list_header.xml
index 6139ff4815..9f581a1d03 100644
--- a/vector/src/main/res/layout/view_sessions_list_header.xml
+++ b/vector/src/main/res/layout/view_sessions_list_header.xml
@@ -13,7 +13,7 @@
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/layout_horizontal_margin"
android:layout_marginTop="20dp"
- app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintEnd_toStartOf="@id/sessionsListHeaderMenu"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Other sessions" />
@@ -29,4 +29,13 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/sessions_list_header_title"
tools:text="For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore. Learn More." />
+
+
diff --git a/vector/src/main/res/menu/menu_other_sessions.xml b/vector/src/main/res/menu/menu_other_sessions.xml
index 8339286fe7..7893575dde 100644
--- a/vector/src/main/res/menu/menu_other_sessions.xml
+++ b/vector/src/main/res/menu/menu_other_sessions.xml
@@ -9,6 +9,11 @@
android:title="@string/device_manager_other_sessions_select"
app:showAsAction="withText|never" />
+
+
-
+
diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt
index c5edfb868d..65da1a9385 100644
--- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt
+++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt
@@ -22,30 +22,41 @@ import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.core.session.clientinfo.MatrixClientInfoContent
import im.vector.app.features.settings.devices.v2.details.extended.DeviceExtendedInfo
import im.vector.app.features.settings.devices.v2.list.DeviceType
+import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase
import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import im.vector.app.features.settings.devices.v2.verification.CurrentSessionCrossSigningInfo
import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder
+import im.vector.app.test.fakes.FakePendingAuthHandler
+import im.vector.app.test.fakes.FakeSignoutSessionsUseCase
import im.vector.app.test.fakes.FakeVerificationService
import im.vector.app.test.test
import im.vector.app.test.testDispatcher
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
-import io.mockk.just
+import io.mockk.justRun
import io.mockk.mockk
import io.mockk.mockkStatic
-import io.mockk.runs
import io.mockk.unmockkAll
import io.mockk.verify
+import io.mockk.verifyAll
import kotlinx.coroutines.flow.flowOf
+import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
+import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
+import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
+
+private const val A_CURRENT_DEVICE_ID = "current-device-id"
+private const val A_DEVICE_ID_1 = "device-id-1"
+private const val A_DEVICE_ID_2 = "device-id-2"
+private const val A_PASSWORD = "password"
class DevicesViewModelTest {
@@ -55,19 +66,25 @@ class DevicesViewModelTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val getCurrentSessionCrossSigningInfoUseCase = mockk()
private val getDeviceFullInfoListUseCase = mockk()
- private val refreshDevicesUseCase = mockk(relaxUnitFun = true)
- private val refreshDevicesOnCryptoDevicesChangeUseCase = mockk()
+ private val refreshDevicesOnCryptoDevicesChangeUseCase = mockk(relaxed = true)
private val checkIfCurrentSessionCanBeVerifiedUseCase = mockk()
+ private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase()
+ private val fakeInterceptSignoutFlowResponseUseCase = mockk()
+ private val fakePendingAuthHandler = FakePendingAuthHandler()
+ private val fakeRefreshDevicesUseCase = mockk(relaxUnitFun = true)
private fun createViewModel(): DevicesViewModel {
return DevicesViewModel(
- DevicesViewState(),
- fakeActiveSessionHolder.instance,
- getCurrentSessionCrossSigningInfoUseCase,
- getDeviceFullInfoListUseCase,
- refreshDevicesOnCryptoDevicesChangeUseCase,
- checkIfCurrentSessionCanBeVerifiedUseCase,
- refreshDevicesUseCase,
+ initialState = DevicesViewState(),
+ activeSessionHolder = fakeActiveSessionHolder.instance,
+ getCurrentSessionCrossSigningInfoUseCase = getCurrentSessionCrossSigningInfoUseCase,
+ getDeviceFullInfoListUseCase = getDeviceFullInfoListUseCase,
+ refreshDevicesOnCryptoDevicesChangeUseCase = refreshDevicesOnCryptoDevicesChangeUseCase,
+ checkIfCurrentSessionCanBeVerifiedUseCase = checkIfCurrentSessionCanBeVerifiedUseCase,
+ signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance,
+ interceptSignoutFlowResponseUseCase = fakeInterceptSignoutFlowResponseUseCase,
+ pendingAuthHandler = fakePendingAuthHandler.instance,
+ refreshDevicesUseCase = fakeRefreshDevicesUseCase,
)
}
@@ -76,6 +93,20 @@ class DevicesViewModelTest {
// Needed for internal usage of Flow.throttleFirst() inside the ViewModel
mockkStatic(SystemClock::class)
every { SystemClock.elapsedRealtime() } returns 1234
+
+ givenVerificationService()
+ givenCurrentSessionCrossSigningInfo()
+ givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2)
+ }
+
+ private fun givenVerificationService(): FakeVerificationService {
+ val fakeVerificationService = fakeActiveSessionHolder
+ .fakeSession
+ .fakeCryptoService
+ .fakeVerificationService
+ fakeVerificationService.givenAddListenerSucceeds()
+ fakeVerificationService.givenRemoveListenerSucceeds()
+ return fakeVerificationService
}
@After
@@ -87,9 +118,6 @@ class DevicesViewModelTest {
fun `given the viewModel when initializing it then verification listener is added`() {
// Given
val fakeVerificationService = givenVerificationService()
- givenCurrentSessionCrossSigningInfo()
- givenDeviceFullInfoList()
- givenRefreshDevicesOnCryptoDevicesChange()
// When
val viewModel = createViewModel()
@@ -104,9 +132,6 @@ class DevicesViewModelTest {
fun `given the viewModel when clearing it then verification listener is removed`() {
// Given
val fakeVerificationService = givenVerificationService()
- givenCurrentSessionCrossSigningInfo()
- givenDeviceFullInfoList()
- givenRefreshDevicesOnCryptoDevicesChange()
// When
val viewModel = createViewModel()
@@ -121,10 +146,7 @@ class DevicesViewModelTest {
@Test
fun `given the viewModel when initializing it then view state is updated with current session cross signing info`() {
// Given
- givenVerificationService()
val currentSessionCrossSigningInfo = givenCurrentSessionCrossSigningInfo()
- givenDeviceFullInfoList()
- givenRefreshDevicesOnCryptoDevicesChange()
// When
val viewModelTest = createViewModel().test()
@@ -137,10 +159,7 @@ class DevicesViewModelTest {
@Test
fun `given the viewModel when initializing it then view state is updated with current device full info list`() {
// Given
- givenVerificationService()
- givenCurrentSessionCrossSigningInfo()
- val deviceFullInfoList = givenDeviceFullInfoList()
- givenRefreshDevicesOnCryptoDevicesChange()
+ val deviceFullInfoList = givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2)
// When
val viewModelTest = createViewModel().test()
@@ -156,10 +175,6 @@ class DevicesViewModelTest {
@Test
fun `given the viewModel when initializing it then devices are refreshed on crypto devices change`() {
// Given
- givenVerificationService()
- givenCurrentSessionCrossSigningInfo()
- givenDeviceFullInfoList()
- givenRefreshDevicesOnCryptoDevicesChange()
// When
createViewModel()
@@ -171,10 +186,6 @@ class DevicesViewModelTest {
@Test
fun `given current session can be verified when handling verify current session action then self verification event is posted`() {
// Given
- givenVerificationService()
- givenCurrentSessionCrossSigningInfo()
- givenDeviceFullInfoList()
- givenRefreshDevicesOnCryptoDevicesChange()
val verifyCurrentSessionAction = DevicesAction.VerifyCurrentSession
coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns true
@@ -195,10 +206,6 @@ class DevicesViewModelTest {
@Test
fun `given current session cannot be verified when handling verify current session action then reset secrets event is posted`() {
// Given
- givenVerificationService()
- givenCurrentSessionCrossSigningInfo()
- givenDeviceFullInfoList()
- givenRefreshDevicesOnCryptoDevicesChange()
val verifyCurrentSessionAction = DevicesAction.VerifyCurrentSession
coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns false
@@ -216,18 +223,129 @@ class DevicesViewModelTest {
}
}
- private fun givenVerificationService(): FakeVerificationService {
- val fakeVerificationService = fakeActiveSessionHolder
- .fakeSession
- .fakeCryptoService
- .fakeVerificationService
- fakeVerificationService.givenAddListenerSucceeds()
- fakeVerificationService.givenRemoveListenerSucceeds()
- return fakeVerificationService
+ @Test
+ fun `given no reAuth is needed when handling multiSignout other sessions action then signout process is performed`() {
+ // Given
+ val expectedViewState = givenInitialViewState(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_CURRENT_DEVICE_ID)
+ // signout all devices except the current device
+ fakeSignoutSessionsUseCase.givenSignoutSuccess(listOf(A_DEVICE_ID_1))
+
+ // When
+ val viewModel = createViewModel()
+ val viewModelTest = viewModel.test()
+ viewModel.handle(DevicesAction.MultiSignoutOtherSessions)
+
+ // Then
+ viewModelTest
+ .assertStatesChanges(
+ expectedViewState,
+ { copy(isLoading = true) },
+ { copy(isLoading = false) }
+ )
+ .assertEvent { it is DevicesViewEvent.SignoutSuccess }
+ .finish()
+ verify {
+ fakeRefreshDevicesUseCase.execute()
+ }
+ }
+
+ @Test
+ fun `given unexpected error during multiSignout when handling multiSignout action then signout process is performed`() {
+ // Given
+ val error = Exception()
+ fakeSignoutSessionsUseCase.givenSignoutError(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2), error)
+ val expectedViewState = givenInitialViewState(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2)
+
+ // When
+ val viewModel = createViewModel()
+ val viewModelTest = viewModel.test()
+ viewModel.handle(DevicesAction.MultiSignoutOtherSessions)
+
+ // Then
+ viewModelTest
+ .assertStatesChanges(
+ expectedViewState,
+ { copy(isLoading = true) },
+ { copy(isLoading = false) }
+ )
+ .assertEvent { it is DevicesViewEvent.SignoutError && it.error == error }
+ .finish()
+ }
+
+ @Test
+ fun `given reAuth is needed during multiSignout when handling multiSignout action then requestReAuth is sent and pending auth is stored`() {
+ // Given
+ val reAuthNeeded = fakeSignoutSessionsUseCase.givenSignoutReAuthNeeded(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2))
+ val expectedPendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session)
+ val expectedReAuthEvent = DevicesViewEvent.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode)
+
+ // When
+ val viewModel = createViewModel()
+ val viewModelTest = viewModel.test()
+ viewModel.handle(DevicesAction.MultiSignoutOtherSessions)
+
+ // Then
+ viewModelTest
+ .assertEvent { it == expectedReAuthEvent }
+ .finish()
+ fakePendingAuthHandler.instance.pendingAuth shouldBeEqualTo expectedPendingAuth
+ fakePendingAuthHandler.instance.uiaContinuation shouldBeEqualTo reAuthNeeded.uiaContinuation
+ }
+
+ @Test
+ fun `given SSO auth has been done when handling ssoAuthDone action then corresponding method of pending auth handler is called`() {
+ // Given
+ justRun { fakePendingAuthHandler.instance.ssoAuthDone() }
+
+ // When
+ val viewModel = createViewModel()
+ val viewModelTest = viewModel.test()
+ viewModel.handle(DevicesAction.SsoAuthDone)
+
+ // Then
+ viewModelTest.finish()
+ verifyAll {
+ fakePendingAuthHandler.instance.ssoAuthDone()
+ }
+ }
+
+ @Test
+ fun `given password auth has been done when handling passwordAuthDone action then corresponding method of pending auth handler is called`() {
+ // Given
+ justRun { fakePendingAuthHandler.instance.passwordAuthDone(any()) }
+
+ // When
+ val viewModel = createViewModel()
+ val viewModelTest = viewModel.test()
+ viewModel.handle(DevicesAction.PasswordAuthDone(A_PASSWORD))
+
+ // Then
+ viewModelTest.finish()
+ verifyAll {
+ fakePendingAuthHandler.instance.passwordAuthDone(A_PASSWORD)
+ }
+ }
+
+ @Test
+ fun `given reAuth has been cancelled when handling reAuthCancelled action then corresponding method of pending auth handler is called`() {
+ // Given
+ justRun { fakePendingAuthHandler.instance.reAuthCancelled() }
+
+ // When
+ val viewModel = createViewModel()
+ val viewModelTest = viewModel.test()
+ viewModel.handle(DevicesAction.ReAuthCancelled)
+
+ // Then
+ viewModelTest.finish()
+ verifyAll {
+ fakePendingAuthHandler.instance.reAuthCancelled()
+ }
}
private fun givenCurrentSessionCrossSigningInfo(): CurrentSessionCrossSigningInfo {
val currentSessionCrossSigningInfo = mockk()
+ every { currentSessionCrossSigningInfo.deviceId } returns A_CURRENT_DEVICE_ID
every { getCurrentSessionCrossSigningInfoUseCase.execute() } returns flowOf(currentSessionCrossSigningInfo)
return currentSessionCrossSigningInfo
}
@@ -235,14 +353,19 @@ class DevicesViewModelTest {
/**
* Generate mocked deviceFullInfo list with 1 unverified and inactive + 1 verified and active.
*/
- private fun givenDeviceFullInfoList(): List {
+ private fun givenDeviceFullInfoList(deviceId1: String, deviceId2: String): List {
val verifiedCryptoDeviceInfo = mockk()
every { verifiedCryptoDeviceInfo.trustLevel } returns DeviceTrustLevel(crossSigningVerified = true, locallyVerified = true)
val unverifiedCryptoDeviceInfo = mockk()
every { unverifiedCryptoDeviceInfo.trustLevel } returns DeviceTrustLevel(crossSigningVerified = false, locallyVerified = false)
+ val deviceInfo1 = mockk()
+ every { deviceInfo1.deviceId } returns deviceId1
+ val deviceInfo2 = mockk()
+ every { deviceInfo2.deviceId } returns deviceId2
+
val deviceFullInfo1 = DeviceFullInfo(
- deviceInfo = mockk(),
+ deviceInfo = deviceInfo1,
cryptoDeviceInfo = verifiedCryptoDeviceInfo,
roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted,
isInactive = false,
@@ -251,7 +374,7 @@ class DevicesViewModelTest {
matrixClientInfo = MatrixClientInfoContent(),
)
val deviceFullInfo2 = DeviceFullInfo(
- deviceInfo = mockk(),
+ deviceInfo = deviceInfo2,
cryptoDeviceInfo = unverifiedCryptoDeviceInfo,
roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Warning,
isInactive = true,
@@ -265,7 +388,15 @@ class DevicesViewModelTest {
return deviceFullInfoList
}
- private fun givenRefreshDevicesOnCryptoDevicesChange() {
- coEvery { refreshDevicesOnCryptoDevicesChangeUseCase.execute() } just runs
+ private fun givenInitialViewState(deviceId1: String, deviceId2: String): DevicesViewState {
+ val currentSessionCrossSigningInfo = givenCurrentSessionCrossSigningInfo()
+ val deviceFullInfoList = givenDeviceFullInfoList(deviceId1, deviceId2)
+ return DevicesViewState(
+ currentSessionCrossSigningInfo = currentSessionCrossSigningInfo,
+ devices = Success(deviceFullInfoList),
+ unverifiedSessionsCount = 1,
+ inactiveSessionsCount = 1,
+ isLoading = false,
+ )
}
}
diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt
index e7b8eeee9b..1e8c511c42 100644
--- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt
+++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt
@@ -24,23 +24,31 @@ import im.vector.app.features.settings.devices.v2.GetDeviceFullInfoListUseCase
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType
import im.vector.app.test.fakes.FakeActiveSessionHolder
+import im.vector.app.test.fakes.FakePendingAuthHandler
+import im.vector.app.test.fakes.FakeSignoutSessionsUseCase
import im.vector.app.test.fakes.FakeVerificationService
import im.vector.app.test.fixtures.aDeviceFullInfo
import im.vector.app.test.test
import im.vector.app.test.testDispatcher
import io.mockk.every
+import io.mockk.justRun
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
+import io.mockk.verify
import io.mockk.verifyAll
import kotlinx.coroutines.flow.flowOf
+import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
+import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
private const val A_TITLE_RES_ID = 1
-private const val A_DEVICE_ID = "device-id"
+private const val A_DEVICE_ID_1 = "device-id-1"
+private const val A_DEVICE_ID_2 = "device-id-2"
+private const val A_PASSWORD = "password"
class OtherSessionsViewModelTest {
@@ -55,14 +63,19 @@ class OtherSessionsViewModelTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val fakeGetDeviceFullInfoListUseCase = mockk()
- private val fakeRefreshDevicesUseCaseUseCase = mockk()
+ private val fakeRefreshDevicesUseCase = mockk(relaxed = true)
+ private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase()
+ private val fakePendingAuthHandler = FakePendingAuthHandler()
- private fun createViewModel(args: OtherSessionsArgs = defaultArgs) = OtherSessionsViewModel(
- initialState = OtherSessionsViewState(args),
- activeSessionHolder = fakeActiveSessionHolder.instance,
- getDeviceFullInfoListUseCase = fakeGetDeviceFullInfoListUseCase,
- refreshDevicesUseCase = fakeRefreshDevicesUseCaseUseCase,
- )
+ private fun createViewModel(viewState: OtherSessionsViewState = OtherSessionsViewState(defaultArgs)) =
+ OtherSessionsViewModel(
+ initialState = viewState,
+ activeSessionHolder = fakeActiveSessionHolder.instance,
+ getDeviceFullInfoListUseCase = fakeGetDeviceFullInfoListUseCase,
+ signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance,
+ pendingAuthHandler = fakePendingAuthHandler.instance,
+ refreshDevicesUseCase = fakeRefreshDevicesUseCase,
+ )
@Before
fun setup() {
@@ -88,6 +101,39 @@ class OtherSessionsViewModelTest {
unmockkAll()
}
+ @Test
+ fun `given the viewModel when initializing it then verification listener is added`() {
+ // Given
+ val fakeVerificationService = givenVerificationService()
+ val devices = mockk
>()
+ 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>()
+ givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
+
+ // When
+ val viewModel = createViewModel()
+ viewModel.onCleared()
+
+ // Then
+ verify {
+ fakeVerificationService.removeListener(viewModel)
+ }
+ }
+
@Test
fun `given the viewModel has been initialized then viewState is updated with devices list`() {
// Given
@@ -143,7 +189,7 @@ class OtherSessionsViewModelTest {
@Test
fun `given enable select mode action when handling the action then viewState is updated with correct info`() {
// Given
- val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID, isSelected = false)
+ val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val devices: List = listOf(deviceFullInfo)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState(
@@ -156,7 +202,7 @@ class OtherSessionsViewModelTest {
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
- viewModel.handle(OtherSessionsAction.EnableSelectMode(A_DEVICE_ID))
+ viewModel.handle(OtherSessionsAction.EnableSelectMode(A_DEVICE_ID_1))
// Then
viewModelTest
@@ -167,8 +213,8 @@ class OtherSessionsViewModelTest {
@Test
fun `given disable select mode action when handling the action then viewState is updated with correct info`() {
// Given
- val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true)
- val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true)
+ val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = true)
+ val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = true)
val devices: List = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState(
@@ -192,7 +238,7 @@ class OtherSessionsViewModelTest {
@Test
fun `given toggle selection for device action when handling the action then viewState is updated with correct info`() {
// Given
- val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID, isSelected = false)
+ val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
val devices: List = listOf(deviceFullInfo)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState(
@@ -205,7 +251,7 @@ class OtherSessionsViewModelTest {
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
- viewModel.handle(OtherSessionsAction.ToggleSelectionForDevice(A_DEVICE_ID))
+ viewModel.handle(OtherSessionsAction.ToggleSelectionForDevice(A_DEVICE_ID_1))
// Then
viewModelTest
@@ -216,8 +262,8 @@ class OtherSessionsViewModelTest {
@Test
fun `given select all action when handling the action then viewState is updated with correct info`() {
// Given
- val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID, isSelected = false)
- val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true)
+ val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
+ val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true)
val devices: List = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState(
@@ -241,8 +287,8 @@ class OtherSessionsViewModelTest {
@Test
fun `given deselect all action when handling the action then viewState is updated with correct info`() {
// Given
- val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID, isSelected = false)
- val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true)
+ val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
+ val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true)
val devices: List = listOf(deviceFullInfo1, deviceFullInfo2)
givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
val expectedState = OtherSessionsViewState(
@@ -263,6 +309,190 @@ class OtherSessionsViewModelTest {
.finish()
}
+ @Test
+ fun `given no reAuth is needed and in selectMode when handling multiSignout action then signout process is performed`() {
+ // Given
+ val isSelectModeEnabled = true
+ val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false)
+ val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true)
+ val devices: List = 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 = 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 = 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 = 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>()
+ 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>()
+ 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>()
+ givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices)
+ justRun { fakePendingAuthHandler.instance.reAuthCancelled() }
+
+ // When
+ val viewModel = createViewModel()
+ val viewModelTest = viewModel.test()
+ viewModel.handle(OtherSessionsAction.ReAuthCancelled)
+
+ // Then
+ viewModelTest.finish()
+ verifyAll {
+ fakePendingAuthHandler.instance.reAuthCancelled()
+ }
+ }
+
private fun givenGetDeviceFullInfoListReturns(
filterType: DeviceManagerFilterType,
devices: List,
diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt
index c0ba6ce28b..f26c818e1d 100644
--- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt
+++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt
@@ -20,18 +20,15 @@ import android.os.SystemClock
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.test.MavericksTestRule
-import im.vector.app.R
import im.vector.app.features.settings.devices.v2.DeviceFullInfo
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase
import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase
import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus
import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase
-import im.vector.app.features.settings.devices.v2.signout.SignoutSessionResult
-import im.vector.app.features.settings.devices.v2.signout.SignoutSessionUseCase
import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakePendingAuthHandler
-import im.vector.app.test.fakes.FakeStringProvider
+import im.vector.app.test.fakes.FakeSignoutSessionsUseCase
import im.vector.app.test.fakes.FakeTogglePushNotificationUseCase
import im.vector.app.test.fakes.FakeVerificationService
import im.vector.app.test.test
@@ -43,7 +40,6 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
-import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import io.mockk.verifyAll
@@ -53,19 +49,11 @@ import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
-import org.matrix.android.sdk.api.auth.UIABaseAuth
-import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
-import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
-import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
-import javax.net.ssl.HttpsURLConnection
-import kotlin.coroutines.Continuation
private const val A_SESSION_ID_1 = "session-id-1"
private const val A_SESSION_ID_2 = "session-id-2"
-private const val AUTH_ERROR_MESSAGE = "auth-error-message"
-private const val AN_ERROR_MESSAGE = "error-message"
private const val A_PASSWORD = "password"
class SessionOverviewViewModelTest {
@@ -81,22 +69,20 @@ class SessionOverviewViewModelTest {
)
private val getDeviceFullInfoUseCase = mockk(relaxed = true)
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
- private val fakeStringProvider = FakeStringProvider()
private val checkIfCurrentSessionCanBeVerifiedUseCase = mockk()
- private val signoutSessionUseCase = mockk()
+ private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase()
private val interceptSignoutFlowResponseUseCase = mockk()
private val fakePendingAuthHandler = FakePendingAuthHandler()
- private val refreshDevicesUseCase = mockk()
+ private val refreshDevicesUseCase = mockk(relaxed = true)
private val togglePushNotificationUseCase = FakeTogglePushNotificationUseCase()
private val fakeGetNotificationsStatusUseCase = mockk()
private val notificationsStatus = NotificationsStatus.ENABLED
private fun createViewModel() = SessionOverviewViewModel(
initialState = SessionOverviewViewState(args),
- stringProvider = fakeStringProvider.instance,
getDeviceFullInfoUseCase = getDeviceFullInfoUseCase,
checkIfCurrentSessionCanBeVerifiedUseCase = checkIfCurrentSessionCanBeVerifiedUseCase,
- signoutSessionUseCase = signoutSessionUseCase,
+ signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance,
interceptSignoutFlowResponseUseCase = interceptSignoutFlowResponseUseCase,
pendingAuthHandler = fakePendingAuthHandler.instance,
activeSessionHolder = fakeActiveSessionHolder.instance,
@@ -115,11 +101,50 @@ class SessionOverviewViewModelTest {
every { fakeGetNotificationsStatusUseCase.execute(A_SESSION_ID_1) } returns flowOf(notificationsStatus)
}
+ private fun givenVerificationService(): FakeVerificationService {
+ val fakeVerificationService = fakeActiveSessionHolder
+ .fakeSession
+ .fakeCryptoService
+ .fakeVerificationService
+ fakeVerificationService.givenAddListenerSucceeds()
+ fakeVerificationService.givenRemoveListenerSucceeds()
+ return fakeVerificationService
+ }
+
@After
fun tearDown() {
unmockkAll()
}
+ @Test
+ fun `given the viewModel when initializing it then verification listener is added`() {
+ // Given
+ val fakeVerificationService = givenVerificationService()
+
+ // When
+ val viewModel = createViewModel()
+
+ // Then
+ verify {
+ fakeVerificationService.addListener(viewModel)
+ }
+ }
+
+ @Test
+ fun `given the viewModel when clearing it then verification listener is removed`() {
+ // Given
+ val fakeVerificationService = givenVerificationService()
+
+ // When
+ val viewModel = createViewModel()
+ viewModel.onCleared()
+
+ // Then
+ verify {
+ fakeVerificationService.removeListener(viewModel)
+ }
+ }
+
@Test
fun `given the viewModel has been initialized then pushers are refreshed`() {
createViewModel()
@@ -223,8 +248,7 @@ class SessionOverviewViewModelTest {
val deviceFullInfo = mockk()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
- givenSignoutSuccess(A_SESSION_ID_1)
- every { refreshDevicesUseCase.execute() } just runs
+ fakeSignoutSessionsUseCase.givenSignoutSuccess(listOf(A_SESSION_ID_1))
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState(
@@ -254,41 +278,6 @@ class SessionOverviewViewModelTest {
}
}
- @Test
- fun `given another session and server error during signout when handling signout action then signout process is performed`() {
- // Given
- val deviceFullInfo = mockk()
- every { deviceFullInfo.isCurrentDevice } returns false
- every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
- val serverError = Failure.OtherServerError(errorBody = "", httpCode = HttpsURLConnection.HTTP_UNAUTHORIZED)
- givenSignoutError(A_SESSION_ID_1, serverError)
- val signoutAction = SessionOverviewAction.SignoutOtherSession
- givenCurrentSessionIsTrusted()
- val expectedViewState = SessionOverviewViewState(
- deviceId = A_SESSION_ID_1,
- isCurrentSessionTrusted = true,
- deviceInfo = Success(deviceFullInfo),
- isLoading = false,
- notificationsStatus = notificationsStatus,
- )
- fakeStringProvider.given(R.string.authentication_error, AUTH_ERROR_MESSAGE)
-
- // When
- val viewModel = createViewModel()
- val viewModelTest = viewModel.test()
- viewModel.handle(signoutAction)
-
- // Then
- viewModelTest
- .assertStatesChanges(
- expectedViewState,
- { copy(isLoading = true) },
- { copy(isLoading = false) }
- )
- .assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error.message == AUTH_ERROR_MESSAGE }
- .finish()
- }
-
@Test
fun `given another session and unexpected error during signout when handling signout action then signout process is performed`() {
// Given
@@ -296,7 +285,7 @@ class SessionOverviewViewModelTest {
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val error = Exception()
- givenSignoutError(A_SESSION_ID_1, error)
+ fakeSignoutSessionsUseCase.givenSignoutError(listOf(A_SESSION_ID_1), error)
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState(
@@ -306,7 +295,6 @@ class SessionOverviewViewModelTest {
isLoading = false,
notificationsStatus = notificationsStatus,
)
- fakeStringProvider.given(R.string.matrix_error, AN_ERROR_MESSAGE)
// When
val viewModel = createViewModel()
@@ -320,7 +308,7 @@ class SessionOverviewViewModelTest {
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
- .assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error.message == AN_ERROR_MESSAGE }
+ .assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error == error }
.finish()
}
@@ -330,7 +318,7 @@ class SessionOverviewViewModelTest {
val deviceFullInfo = mockk()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
- val reAuthNeeded = givenSignoutReAuthNeeded(A_SESSION_ID_1)
+ val reAuthNeeded = fakeSignoutSessionsUseCase.givenSignoutReAuthNeeded(listOf(A_SESSION_ID_1))
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedPendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session)
@@ -415,53 +403,6 @@ class SessionOverviewViewModelTest {
}
}
- private fun givenSignoutSuccess(deviceId: String) {
- val interceptor = slot()
- val flowResponse = mockk()
- val errorCode = "errorCode"
- val promise = mockk>()
- every { interceptSignoutFlowResponseUseCase.execute(flowResponse, errorCode, promise) } returns SignoutSessionResult.Completed
- coEvery { signoutSessionUseCase.execute(deviceId, capture(interceptor)) } coAnswers {
- secondArg().performStage(flowResponse, errorCode, promise)
- Result.success(Unit)
- }
- }
-
- private fun givenSignoutReAuthNeeded(deviceId: String): SignoutSessionResult.ReAuthNeeded {
- val interceptor = slot()
- val flowResponse = mockk()
- every { flowResponse.session } returns A_SESSION_ID_1
- val errorCode = "errorCode"
- val promise = mockk>()
- 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().performStage(flowResponse, errorCode, promise)
- Result.success(Unit)
- }
-
- return reAuthNeeded
- }
-
- private fun givenSignoutError(deviceId: String, error: Throwable) {
- coEvery { signoutSessionUseCase.execute(deviceId, any()) } returns Result.failure(error)
- }
-
- private fun givenVerificationService(): FakeVerificationService {
- val fakeVerificationService = fakeActiveSessionHolder
- .fakeSession
- .fakeCryptoService
- .fakeVerificationService
- fakeVerificationService.givenAddListenerSucceeds()
- fakeVerificationService.givenRemoveListenerSucceeds()
- return fakeVerificationService
- }
-
private fun givenCurrentSessionIsTrusted() {
fakeActiveSessionHolder.fakeSession.givenSessionId(A_SESSION_ID_2)
val deviceFullInfo = mockk()
diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCaseTest.kt
index 35551ba36e..cd0575f2a0 100644
--- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCaseTest.kt
+++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCaseTest.kt
@@ -24,8 +24,8 @@ import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkAll
+import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeEqualTo
-import org.amshove.kluent.shouldBeInstanceOf
import org.junit.After
import org.junit.Before
import org.junit.Test
@@ -63,7 +63,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
}
@Test
- fun `given no error and a stored password and a next stage as password when intercepting then promise is resumed and success is returned`() {
+ fun `given no error and a stored password and a next stage as password when intercepting then promise is resumed and null is returned`() {
// Given
val registrationFlowResponse = givenNextUncompletedStage(LoginFlowTypes.PASSWORD, A_SESSION_ID)
fakeReAuthHelper.givenStoredPassword(A_PASSWORD)
@@ -84,7 +84,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
)
// Then
- result shouldBeInstanceOf (SignoutSessionResult.Completed::class)
+ result shouldBe null
every {
promise.resume(expectedAuth)
}
@@ -97,7 +97,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
fakeReAuthHelper.givenStoredPassword(A_PASSWORD)
val errorCode = AN_ERROR_CODE
val promise = mockk>()
- val expectedResult = SignoutSessionResult.ReAuthNeeded(
+ val expectedResult = SignoutSessionsReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = A_SESSION_ID),
uiaContinuation = promise,
flowResponse = registrationFlowResponse,
@@ -122,7 +122,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
fakeReAuthHelper.givenStoredPassword(A_PASSWORD)
val errorCode: String? = null
val promise = mockk>()
- val expectedResult = SignoutSessionResult.ReAuthNeeded(
+ val expectedResult = SignoutSessionsReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = A_SESSION_ID),
uiaContinuation = promise,
flowResponse = registrationFlowResponse,
@@ -147,7 +147,7 @@ class InterceptSignoutFlowResponseUseCaseTest {
fakeReAuthHelper.givenStoredPassword(null)
val errorCode: String? = null
val promise = mockk>()
- val expectedResult = SignoutSessionResult.ReAuthNeeded(
+ val expectedResult = SignoutSessionsReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = A_SESSION_ID),
uiaContinuation = promise,
flowResponse = registrationFlowResponse,
diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCaseTest.kt
deleted file mode 100644
index 5af91c16ce..0000000000
--- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCaseTest.kt
+++ /dev/null
@@ -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()
- 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()
-}
diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCaseTest.kt
new file mode 100644
index 0000000000..70d2b4b039
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCaseTest.kt
@@ -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()
+
+ 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()
+ 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 = {}
+}
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeCryptoService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeCryptoService.kt
index e96a58faa0..b23f018cf5 100644
--- a/vector/src/test/java/im/vector/app/test/fakes/FakeCryptoService.kt
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeCryptoService.kt
@@ -22,6 +22,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import org.matrix.android.sdk.api.MatrixCallback
+import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.session.crypto.CryptoService
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
@@ -70,16 +71,21 @@ class FakeCryptoService(
}
}
- fun givenDeleteDeviceSucceeds(deviceId: String) {
- val matrixCallback = slot>()
- every { deleteDevice(deviceId, any(), capture(matrixCallback)) } answers {
+ fun givenDeleteDevicesSucceeds(deviceIds: List) {
+ every { deleteDevices(deviceIds, any(), any()) } answers {
thirdArg>().onSuccess(Unit)
}
}
- fun givenDeleteDeviceFailsWithError(deviceId: String, error: Exception) {
- val matrixCallback = slot>()
- every { deleteDevice(deviceId, any(), capture(matrixCallback)) } answers {
+ fun givenDeleteDevicesNeedsUIAuth(deviceIds: List) {
+ every { deleteDevices(deviceIds, any(), any()) } answers {
+ secondArg().performStage(mockk(), "", mockk())
+ thirdArg>().onSuccess(Unit)
+ }
+ }
+
+ fun givenDeleteDevicesFailsWithError(deviceIds: List, error: Exception) {
+ every { deleteDevices(deviceIds, any(), any()) } answers {
thirdArg>().onFailure(error)
}
}
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSignoutSessionsUseCase.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSignoutSessionsUseCase.kt
new file mode 100644
index 0000000000..9eb3676475
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSignoutSessionsUseCase.kt
@@ -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()
+
+ fun givenSignoutSuccess(deviceIds: List) {
+ coEvery { instance.execute(deviceIds, any()) } returns Result.success(Unit)
+ }
+
+ fun givenSignoutReAuthNeeded(deviceIds: List): SignoutSessionsReAuthNeeded {
+ val flowResponse = mockk()
+ 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, error: Throwable) {
+ coEvery { instance.execute(deviceIds, any()) } returns Result.failure(error)
+ }
+}