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) + } +}