From e5e0464929db5987226c54e0b52bd86563a375b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= Date: Mon, 4 Nov 2024 10:53:57 +0000 Subject: [PATCH 1/9] [PM-12406] Introduce new endpoint and replace SSO details response flow (#4177) --- .../api/UnauthenticatedOrganizationApi.kt | 10 ++ ...rganizationDomainSsoDetailsResponseJson.kt | 14 +- ...fiedOrganizationDomainSsoDetailsRequest.kt | 14 ++ ...iedOrganizationDomainSsoDetailsResponse.kt | 35 +++++ .../network/service/OrganizationService.kt | 9 ++ .../service/OrganizationServiceImpl.kt | 11 ++ .../data/auth/repository/AuthRepository.kt | 8 + .../auth/repository/AuthRepositoryImpl.kt | 17 ++ .../OrganizationDomainSsoDetailsResult.kt | 4 + ...ifiedOrganizationDomainSsoDetailsResult.kt | 22 +++ .../data/platform/manager/model/FlagKey.kt | 9 ++ .../EnterpriseSignOnViewModel.kt | 88 ++++++++++- .../components/FeatureFlagListItems.kt | 2 + app/src/main/res/values/strings.xml | 1 + .../service/OrganizationServiceTest.kt | 62 +++++++- .../auth/repository/AuthRepositoryTest.kt | 46 ++++++ .../EnterpriseSignOnViewModelTest.kt | 148 ++++++++++++++++++ .../debugmenu/DebugMenuViewModelTest.kt | 2 + 18 files changed, 490 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsRequest.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsResponse.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VerifiedOrganizationDomainSsoDetailsResult.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedOrganizationApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedOrganizationApi.kt index 20f3c70bf..ec26cb922 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedOrganizationApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedOrganizationApi.kt @@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.api import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsRequest +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse import retrofit2.http.Body import retrofit2.http.POST @@ -16,4 +18,12 @@ interface UnauthenticatedOrganizationApi { suspend fun getClaimedDomainOrganizationDetails( @Body body: OrganizationDomainSsoDetailsRequestJson, ): Result + + /** + * Checks for the verfied organization domains of an email for SSO purposes. + */ + @POST("/organizations/domain/sso/verified") + suspend fun getVerifiedOrganizationDomainsByEmail( + @Body body: VerifiedOrganizationDomainSsoDetailsRequest, + ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/OrganizationDomainSsoDetailsResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/OrganizationDomainSsoDetailsResponseJson.kt index ad03c30f4..df4dc3806 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/OrganizationDomainSsoDetailsResponseJson.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/OrganizationDomainSsoDetailsResponseJson.kt @@ -1,16 +1,26 @@ package com.x8bit.bitwarden.data.auth.datasource.network.model +import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import java.time.ZonedDateTime /** * Response object returned when requesting organization domain SSO details. * * @property isSsoAvailable Whether or not SSO is available for this domain. * @property organizationIdentifier The organization's identifier. + * @property verifiedDate The date the domain was verified. */ @Serializable data class OrganizationDomainSsoDetailsResponseJson( - @SerialName("ssoAvailable") val isSsoAvailable: Boolean, - @SerialName("organizationIdentifier") val organizationIdentifier: String, + @SerialName("ssoAvailable") + val isSsoAvailable: Boolean, + + @SerialName("organizationIdentifier") + val organizationIdentifier: String, + + @SerialName("verifiedDate") + @Contextual + val verifiedDate: ZonedDateTime?, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsRequest.kt new file mode 100644 index 000000000..653f1686a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsRequest.kt @@ -0,0 +1,14 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Request body object when retrieving organization verified domain SSO info. + * + * @param email The email address to check against. + */ +@Serializable +data class VerifiedOrganizationDomainSsoDetailsRequest( + @SerialName("email") val email: String, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsResponse.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsResponse.kt new file mode 100644 index 000000000..7f0337795 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifiedOrganizationDomainSsoDetailsResponse.kt @@ -0,0 +1,35 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Response object returned when requesting organization verified domain SSO details. + * + * @property verifiedOrganizationDomainSsoDetails The list of verified organization domain SSO + * details. + */ +@Serializable +data class VerifiedOrganizationDomainSsoDetailsResponse( + @SerialName("data") + val verifiedOrganizationDomainSsoDetails: List, +) { + /** + * Response body for an organization verified domain SSO details. + * + * @property organizationName The name of the organization. + * @property organizationIdentifier The identifier of the organization. + * @property domainName The name of the domain. + */ + @Serializable + data class VerifiedOrganizationDomainSsoDetail( + @SerialName("organizationName") + val organizationName: String, + + @SerialName("organizationIdentifier") + val organizationIdentifier: String, + + @SerialName("domainName") + val domainName: String, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationService.kt index d25d6765d..0febe90f8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationService.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationAutoEnrollStatusResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationKeysResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse /** * Provides an API for querying organization endpoints. @@ -38,4 +39,12 @@ interface OrganizationService { suspend fun getOrganizationKeys( organizationId: String, ): Result + + /** + * Request organization verified domain details for an [email] needed for SSO + * requests. + */ + suspend fun getVerifiedOrganizationDomainSsoDetails( + email: String, + ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceImpl.kt index 8de08bd27..f4d857da9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceImpl.kt @@ -7,6 +7,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomain import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationKeysResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationResetPasswordEnrollRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsRequest +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse /** * Default implementation of [OrganizationService]. @@ -52,4 +54,13 @@ class OrganizationServiceImpl( .getOrganizationKeys( organizationId = organizationId, ) + + override suspend fun getVerifiedOrganizationDomainSsoDetails( + email: String, + ): Result = unauthenticatedOrganizationApi + .getVerifiedOrganizationDomainsByEmail( + body = VerifiedOrganizationDomainSsoDetailsRequest( + email = email, + ), + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index fc316f28d..b9102b146 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -28,6 +28,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult +import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult @@ -329,6 +330,13 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { email: String, ): OrganizationDomainSsoDetailsResult + /** + * Get the verified organization domain SSO details for the given [email]. + */ + suspend fun getVerifiedOrganizationDomainSsoDetails( + email: String, + ): VerifiedOrganizationDomainSsoDetailsResult + /** * Prevalidates the organization identifier used in an SSO request. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 88f22b081..bc76f5131 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -68,6 +68,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType +import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.model.toLoginErrorResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult @@ -1123,11 +1124,27 @@ class AuthRepositoryImpl( OrganizationDomainSsoDetailsResult.Success( isSsoAvailable = it.isSsoAvailable, organizationIdentifier = it.organizationIdentifier, + verifiedDate = it.verifiedDate, ) }, onFailure = { OrganizationDomainSsoDetailsResult.Failure }, ) + override suspend fun getVerifiedOrganizationDomainSsoDetails( + email: String, + ): VerifiedOrganizationDomainSsoDetailsResult = organizationService + .getVerifiedOrganizationDomainSsoDetails( + email = email, + ) + .fold( + onSuccess = { + VerifiedOrganizationDomainSsoDetailsResult.Success( + verifiedOrganizationDomainSsoDetails = it.verifiedOrganizationDomainSsoDetails, + ) + }, + onFailure = { VerifiedOrganizationDomainSsoDetailsResult.Failure }, + ) + override suspend fun prevalidateSso( organizationIdentifier: String, ): PrevalidateSsoResult = identityService diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/OrganizationDomainSsoDetailsResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/OrganizationDomainSsoDetailsResult.kt index 27bdcc399..70afde04f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/OrganizationDomainSsoDetailsResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/OrganizationDomainSsoDetailsResult.kt @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.data.auth.repository.model +import java.time.ZonedDateTime + /** * Response types when checking for an email's claimed domain organization. */ @@ -9,10 +11,12 @@ sealed class OrganizationDomainSsoDetailsResult { * * @property isSsoAvailable Indicates if SSO is available for the email address. * @property organizationIdentifier The claimed organization identifier for the email address. + * @property verifiedDate The date and time when the domain was verified. */ data class Success( val isSsoAvailable: Boolean, val organizationIdentifier: String, + val verifiedDate: ZonedDateTime?, ) : OrganizationDomainSsoDetailsResult() /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VerifiedOrganizationDomainSsoDetailsResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VerifiedOrganizationDomainSsoDetailsResult.kt new file mode 100644 index 000000000..bab873666 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VerifiedOrganizationDomainSsoDetailsResult.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse.VerifiedOrganizationDomainSsoDetail + +/** + * Response types when checking for an email's claimed domain organization. + */ +sealed class VerifiedOrganizationDomainSsoDetailsResult { + /** + * The request was successful. + * + * @property verifiedOrganizationDomainSsoDetails The verified organization domain SSO details. + */ + data class Success( + val verifiedOrganizationDomainSsoDetails: List, + ) : VerifiedOrganizationDomainSsoDetailsResult() + + /** + * The request failed. + */ + data object Failure : VerifiedOrganizationDomainSsoDetailsResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt index 861711879..60d81bd42 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt @@ -32,6 +32,7 @@ sealed class FlagKey { OnboardingCarousel, ImportLoginsFlow, SshKeyCipherItems, + VerifiedSsoDomainEndpoint, ) } } @@ -89,6 +90,14 @@ sealed class FlagKey { override val defaultValue: Boolean = false override val isRemotelyConfigured: Boolean = true } + /** + * Data object holding the feature flag key for the new verified SSO domain endpoint feature. + */ + data object VerifiedSsoDomainEndpoint : FlagKey() { + override val keyName: String = "pm-12337-refactor-sso-details-endpoint" + override val defaultValue: Boolean = false + override val isRemotelyConfigured: Boolean = true + } /** * Data object holding the key for a [Boolean] flag to be used in tests. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt index 243c61327..1ae84ce2a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt @@ -9,12 +9,15 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult +import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.SSO_URI import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha import com.x8bit.bitwarden.data.auth.repository.util.generateUriForSso +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.NetworkConnectionManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.util.baseIdentityUrl import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository @@ -43,6 +46,7 @@ private const val RANDOM_STRING_LENGTH = 64 class EnterpriseSignOnViewModel @Inject constructor( private val authRepository: AuthRepository, private val environmentRepository: EnvironmentRepository, + private val featureFlagManager: FeatureFlagManager, private val generatorRepository: GeneratorRepository, private val networkConnectionManager: NetworkConnectionManager, private val savedStateHandle: SavedStateHandle, @@ -123,6 +127,10 @@ class EnterpriseSignOnViewModel @Inject constructor( is EnterpriseSignOnAction.Internal.OnReceiveCaptchaToken -> { handleOnReceiveCaptchaToken(action) } + + is EnterpriseSignOnAction.Internal.OnVerifiedOrganizationDomainSsoDetailsReceive -> { + handleOnVerifiedOrganizationDomainSsoDetailsReceive(action) + } } } @@ -209,6 +217,54 @@ class EnterpriseSignOnViewModel @Inject constructor( } } + private fun handleOnVerifiedOrganizationDomainSsoDetailsReceive( + action: EnterpriseSignOnAction.Internal.OnVerifiedOrganizationDomainSsoDetailsReceive, + ) { + when (val orgDetails = action.verifiedOrgDomainSsoDetailsResult) { + is VerifiedOrganizationDomainSsoDetailsResult.Failure -> { + mutableStateFlow.update { + it.copy( + dialogState = null, + orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "", + ) + } + } + + is VerifiedOrganizationDomainSsoDetailsResult.Success -> { + handleOnVerifiedOrganizationDomainSsoDetailsSuccess(orgDetails) + } + } + } + + private fun handleOnVerifiedOrganizationDomainSsoDetailsSuccess( + orgDetails: VerifiedOrganizationDomainSsoDetailsResult.Success, + ) { + if (orgDetails.verifiedOrganizationDomainSsoDetails.isEmpty()) { + mutableStateFlow.update { + it.copy( + dialogState = null, + orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "", + ) + } + return + } + + val organizationIdentifier = orgDetails + .verifiedOrganizationDomainSsoDetails + .first() + .organizationIdentifier + + mutableStateFlow.update { + it.copy( + orgIdentifierInput = organizationIdentifier, + ) + } + + // If the email address is associated with a claimed organization we can proceed to the + // prevalidation step. + prevalidateSso() + } + private fun handleOnOrganizationDomainSsoDetailsReceive( action: EnterpriseSignOnAction.Internal.OnOrganizationDomainSsoDetailsReceive, ) { @@ -226,7 +282,7 @@ class EnterpriseSignOnViewModel @Inject constructor( private fun handleOnOrganizationDomainSsoDetailsSuccess( orgDetails: OrganizationDomainSsoDetailsResult.Success, ) { - if (!orgDetails.isSsoAvailable) { + if (!orgDetails.isSsoAvailable || orgDetails.verifiedDate == null) { mutableStateFlow.update { it.copy( dialogState = null, @@ -375,12 +431,23 @@ class EnterpriseSignOnViewModel @Inject constructor( ) } viewModelScope.launch { - val result = authRepository.getOrganizationDomainSsoDetails( - email = EnterpriseSignOnArgs(savedStateHandle).emailAddress, - ) - sendAction( - EnterpriseSignOnAction.Internal.OnOrganizationDomainSsoDetailsReceive(result), - ) + if (featureFlagManager.getFeatureFlag(key = FlagKey.VerifiedSsoDomainEndpoint)) { + val result = authRepository.getVerifiedOrganizationDomainSsoDetails( + email = EnterpriseSignOnArgs(savedStateHandle).emailAddress, + ) + sendAction( + EnterpriseSignOnAction.Internal.OnVerifiedOrganizationDomainSsoDetailsReceive( + result, + ), + ) + } else { + val result = authRepository.getOrganizationDomainSsoDetails( + email = EnterpriseSignOnArgs(savedStateHandle).emailAddress, + ) + sendAction( + EnterpriseSignOnAction.Internal.OnOrganizationDomainSsoDetailsReceive(result), + ) + } } } @@ -561,6 +628,13 @@ sealed class EnterpriseSignOnAction { * A captcha callback result has been received */ data class OnReceiveCaptchaToken(val tokenResult: CaptchaCallbackTokenResult) : Internal() + + /** + * A result was received when requesting an [VerifiedOrganizationDomainSsoDetailsResult]. + */ + data class OnVerifiedOrganizationDomainSsoDetailsReceive( + val verifiedOrgDomainSsoDetailsResult: VerifiedOrganizationDomainSsoDetailsResult, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt index 26b276ccb..98e7342db 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt @@ -28,6 +28,7 @@ fun FlagKey.ListItemContent( FlagKey.OnboardingFlow, FlagKey.ImportLoginsFlow, FlagKey.SshKeyCipherItems, + FlagKey.VerifiedSsoDomainEndpoint, -> BooleanFlagItem( label = flagKey.getDisplayLabel(), key = flagKey as FlagKey, @@ -71,4 +72,5 @@ private fun FlagKey.getDisplayLabel(): String = when (this) { FlagKey.OnboardingFlow -> stringResource(R.string.onboarding_flow) FlagKey.ImportLoginsFlow -> stringResource(R.string.import_logins_flow) FlagKey.SshKeyCipherItems -> stringResource(R.string.ssh_key_cipher_item_types) + FlagKey.VerifiedSsoDomainEndpoint -> stringResource(R.string.verified_sso_domain_verified) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c817e004..8949e5404 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1073,6 +1073,7 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Verified SSO Domain Endpoint Logins imported Remember to delete your imported password file from your computer SSH key diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceTest.kt index 8c3e0f549..aea0efb7d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/OrganizationServiceTest.kt @@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.api.UnauthenticatedOrgan import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationAutoEnrollStatusResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationKeysResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse import com.x8bit.bitwarden.data.platform.base.BaseServiceTest import com.x8bit.bitwarden.data.platform.util.asSuccess import kotlinx.coroutines.test.runTest @@ -13,6 +14,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import retrofit2.create +import java.time.ZonedDateTime class OrganizationServiceTest : BaseServiceTest() { private val authenticatedOrganizationApi: AuthenticatedOrganizationApi = retrofit.create() @@ -55,7 +57,9 @@ class OrganizationServiceTest : BaseServiceTest() { runTest { val email = "test@gmail.com" server.enqueue( - MockResponse().setResponseCode(200).setBody(ORGANIZATION_DOMAIN_SSO_DETAILS_JSON), + MockResponse() + .setResponseCode(200) + .setBody(ORGANIZATION_DOMAIN_SSO_DETAILS_JSON), ) val result = organizationService.getOrganizationDomainSsoDetails(email) assertEquals(ORGANIZATION_DOMAIN_SSO_BODY.asSuccess(), result) @@ -74,7 +78,9 @@ class OrganizationServiceTest : BaseServiceTest() { fun `getOrganizationAutoEnrollStatus when response is success should return valid response`() = runTest { server.enqueue( - MockResponse().setResponseCode(200).setBody(ORGANIZATION_AUTO_ENROLL_STATUS_JSON), + MockResponse() + .setResponseCode(200) + .setBody(ORGANIZATION_AUTO_ENROLL_STATUS_JSON), ) val result = organizationService.getOrganizationAutoEnrollStatus("orgId") assertEquals(ORGANIZATION_AUTO_ENROLL_STATUS_RESPONSE.asSuccess(), result) @@ -91,7 +97,9 @@ class OrganizationServiceTest : BaseServiceTest() { @Test fun `getOrganizationKeys when response is success should return valid response`() = runTest { server.enqueue( - MockResponse().setResponseCode(200).setBody(ORGANIZATION_KEYS_JSON), + MockResponse() + .setResponseCode(200) + .setBody(ORGANIZATION_KEYS_JSON), ) val result = organizationService.getOrganizationKeys("orgId") assertEquals(ORGANIZATION_KEYS_RESPONSE.asSuccess(), result) @@ -103,6 +111,30 @@ class OrganizationServiceTest : BaseServiceTest() { val result = organizationService.getOrganizationKeys("orgId") assertTrue(result.isFailure) } + + @Suppress("MaxLineLength") + @Test + fun `getVerifiedOrganizationDomainSsoDetails when response is success should return valid response`() = + runTest { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(ORGANIZATION_VERIFIED_DOMAIN_SSO_DETAILS_JSON), + ) + val result = + organizationService.getVerifiedOrganizationDomainSsoDetails("example@bitwarden.com") + assertEquals(ORGANIZATION_VERIFIED_DOMAIN_SSO_DETAILS_RESPONSE.asSuccess(), result) + } + + @Suppress("MaxLineLength") + @Test + fun `getVerifiedOrganizationDomainSsoDetails when response is an error should return an error`() = + runTest { + server.enqueue(MockResponse().setResponseCode(400)) + val result = + organizationService.getVerifiedOrganizationDomainSsoDetails("example@bitwarden.com") + assertTrue(result.isFailure) + } } private const val ORGANIZATION_AUTO_ENROLL_STATUS_JSON = """ @@ -130,6 +162,7 @@ private const val ORGANIZATION_DOMAIN_SSO_DETAILS_JSON = """ private val ORGANIZATION_DOMAIN_SSO_BODY = OrganizationDomainSsoDetailsResponseJson( isSsoAvailable = true, organizationIdentifier = "Test Org", + verifiedDate = ZonedDateTime.parse("2024-09-13T00:00:00.000Z"), ) private const val ORGANIZATION_KEYS_JSON = """ @@ -143,3 +176,26 @@ private val ORGANIZATION_KEYS_RESPONSE = OrganizationKeysResponseJson( privateKey = "privateKey", publicKey = "publicKey", ) + +private const val ORGANIZATION_VERIFIED_DOMAIN_SSO_DETAILS_JSON = """ +{ + "data": [ + { + "organizationIdentifier": "Test Identifier", + "organizationName": "Bitwarden", + "domainName": "bitwarden.com" + } + ] +} +""" + +private val ORGANIZATION_VERIFIED_DOMAIN_SSO_DETAILS_RESPONSE = + VerifiedOrganizationDomainSsoDetailsResponse( + verifiedOrganizationDomainSsoDetails = listOf( + VerifiedOrganizationDomainSsoDetailsResponse.VerifiedOrganizationDomainSsoDetail( + organizationIdentifier = "Test Identifier", + organizationName = "Bitwarden", + domainName = "bitwarden.com", + ), + ), + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 2e437eeb6..887c7139a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -43,6 +43,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserD import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService @@ -85,6 +86,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType +import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult @@ -149,6 +151,7 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.time.ZonedDateTime @Suppress("LargeClass") class AuthRepositoryTest { @@ -5217,6 +5220,7 @@ class AuthRepositoryTest { } returns OrganizationDomainSsoDetailsResponseJson( isSsoAvailable = true, organizationIdentifier = "Test Org", + verifiedDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), ) .asSuccess() val result = repository.getOrganizationDomainSsoDetails(email) @@ -5224,11 +5228,53 @@ class AuthRepositoryTest { OrganizationDomainSsoDetailsResult.Success( isSsoAvailable = true, organizationIdentifier = "Test Org", + verifiedDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), ), result, ) } + @Suppress("MaxLineLength") + @Test + fun `getVerifiedOrganizationDomainSsoDetails Success should return Success`() = runTest { + val email = "test@gmail.com" + coEvery { + organizationService.getVerifiedOrganizationDomainSsoDetails(email) + } returns VerifiedOrganizationDomainSsoDetailsResponse( + verifiedOrganizationDomainSsoDetails = listOf( + VerifiedOrganizationDomainSsoDetailsResponse.VerifiedOrganizationDomainSsoDetail( + organizationIdentifier = "Test Identifier", + organizationName = "Bitwarden", + domainName = "bitwarden.com", + ), + ), + ).asSuccess() + val result = repository.getVerifiedOrganizationDomainSsoDetails(email) + assertEquals( + VerifiedOrganizationDomainSsoDetailsResult.Success( + verifiedOrganizationDomainSsoDetails = listOf( + VerifiedOrganizationDomainSsoDetailsResponse.VerifiedOrganizationDomainSsoDetail( + organizationIdentifier = "Test Identifier", + organizationName = "Bitwarden", + domainName = "bitwarden.com", + ), + ), + ), + result, + ) + } + + @Test + fun `getVerifiedOrganizationDomainSsoDetails Failure should return Failure `() = runTest { + val email = "test@gmail.com" + val throwable = Throwable() + coEvery { + organizationService.getVerifiedOrganizationDomainSsoDetails(email) + } returns throwable.asFailure() + val result = repository.getVerifiedOrganizationDomainSsoDetails(email) + assertEquals(VerifiedOrganizationDomainSsoDetailsResult.Failure, result) + } + @Test fun `prevalidateSso Failure should return Failure `() = runTest { val organizationId = "organizationid" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt index 0b8497bdf..4e8aac044 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt @@ -4,14 +4,18 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifiedOrganizationDomainSsoDetailsResponse import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult +import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha import com.x8bit.bitwarden.data.auth.repository.util.generateUriForSso +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.util.FakeNetworkConnectionManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository @@ -34,6 +38,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.time.ZonedDateTime @Suppress("LargeClass") class EnterpriseSignOnViewModelTest : BaseViewModelTest() { @@ -54,6 +59,12 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { private val generatorRepository: GeneratorRepository = FakeGeneratorRepository() + private val featureFlagManager = mockk() { + every { + getFeatureFlag(FlagKey.VerifiedSsoDomainEndpoint) + } returns false + } + @BeforeEach fun setUp() { mockkStatic(::generateUriForSso) @@ -694,6 +705,37 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { val orgDetails = OrganizationDomainSsoDetailsResult.Success( isSsoAvailable = false, organizationIdentifier = "Bitwarden without SSO", + verifiedDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), + ) + + coEvery { + authRepository.getOrganizationDomainSsoDetails(any()) + } returns orgDetails + + coEvery { + authRepository.rememberedOrgIdentifier + } returns "Bitwarden" + + val viewModel = createViewModel(dismissInitialDialog = false) + assertEquals( + DEFAULT_STATE.copy(orgIdentifierInput = "Bitwarden"), + viewModel.stateFlow.value, + ) + + coVerify(exactly = 1) { + authRepository.getOrganizationDomainSsoDetails(DEFAULT_EMAIL) + authRepository.rememberedOrgIdentifier + } + } + + @Suppress("MaxLineLength") + @Test + fun `OrganizationDomainSsoDetails success with no verified date available should make a request, hide the dialog, and update the org input based on the remembered org`() = + runTest { + val orgDetails = OrganizationDomainSsoDetailsResult.Success( + isSsoAvailable = true, + organizationIdentifier = "Bitwarden without SSO", + verifiedDate = null, ) coEvery { @@ -723,6 +765,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { val orgDetails = OrganizationDomainSsoDetailsResult.Success( isSsoAvailable = true, organizationIdentifier = "", + verifiedDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), ) coEvery { @@ -757,6 +800,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { val orgDetails = OrganizationDomainSsoDetailsResult.Success( isSsoAvailable = true, organizationIdentifier = "Bitwarden with SSO", + verifiedDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"), ) coEvery { @@ -784,6 +828,109 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `VerifiedOrganizationDomainSsoDetails success with valid organization should make a request then attempt to login`() = + runTest { + val orgDetails = VerifiedOrganizationDomainSsoDetailsResult.Success( + verifiedOrganizationDomainSsoDetails = listOf( + VerifiedOrganizationDomainSsoDetailsResponse.VerifiedOrganizationDomainSsoDetail( + organizationIdentifier = "Bitwarden with SSO", + organizationName = "Bitwarden", + domainName = "bitwarden.com", + ), + ), + ) + + coEvery { + authRepository.getVerifiedOrganizationDomainSsoDetails(any()) + } returns orgDetails + + coEvery { + featureFlagManager.getFeatureFlag(FlagKey.VerifiedSsoDomainEndpoint) + } returns true + + // Just hang on this request; login is tested elsewhere + coEvery { + authRepository.prevalidateSso(any()) + } just awaits + + val viewModel = createViewModel(dismissInitialDialog = false) + assertEquals( + DEFAULT_STATE.copy( + orgIdentifierInput = "Bitwarden with SSO", + dialogState = EnterpriseSignOnState.DialogState.Loading( + message = R.string.logging_in.asText(), + ), + ), + viewModel.stateFlow.value, + ) + + coVerify(exactly = 1) { + authRepository.getVerifiedOrganizationDomainSsoDetails(DEFAULT_EMAIL) + } + } + + @Suppress("MaxLineLength") + @Test + fun `VerifiedOrganizationDomainSsoDetails success with no verified domains should make a request, hide the dialog, and update the org input based on the remembered org`() = + runTest { + val orgDetails = VerifiedOrganizationDomainSsoDetailsResult.Success( + verifiedOrganizationDomainSsoDetails = emptyList(), + ) + + coEvery { + authRepository.getVerifiedOrganizationDomainSsoDetails(any()) + } returns orgDetails + + coEvery { + featureFlagManager.getFeatureFlag(FlagKey.VerifiedSsoDomainEndpoint) + } returns true + + val viewModel = createViewModel(dismissInitialDialog = false) + assertEquals( + DEFAULT_STATE.copy( + orgIdentifierInput = "", + dialogState = null, + ), + viewModel.stateFlow.value, + ) + + coVerify(exactly = 1) { + authRepository.getVerifiedOrganizationDomainSsoDetails(DEFAULT_EMAIL) + } + } + + @Suppress("MaxLineLength") + @Test + fun `VerifiedOrganizationDomainSsoDetails failure should make a request, hide dialog, load from remembered org identifier`() = + runTest { + coEvery { + authRepository.getVerifiedOrganizationDomainSsoDetails(any()) + } returns VerifiedOrganizationDomainSsoDetailsResult.Failure + + coEvery { + featureFlagManager.getFeatureFlag(FlagKey.VerifiedSsoDomainEndpoint) + } returns true + + coEvery { + authRepository.rememberedOrgIdentifier + } returns "Bitwarden" + + val viewModel = createViewModel(dismissInitialDialog = false) + assertEquals( + DEFAULT_STATE.copy( + orgIdentifierInput = "Bitwarden", + dialogState = null, + ), + viewModel.stateFlow.value, + ) + + coVerify(exactly = 1) { + authRepository.getVerifiedOrganizationDomainSsoDetails(DEFAULT_EMAIL) + } + } + @Suppress("LongParameterList") private fun createViewModel( initialState: EnterpriseSignOnState? = null, @@ -802,6 +949,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { ): EnterpriseSignOnViewModel = EnterpriseSignOnViewModel( authRepository = authRepository, environmentRepository = environmentRepository, + featureFlagManager = featureFlagManager, generatorRepository = generatorRepository, networkConnectionManager = FakeNetworkConnectionManager(isNetworkConnected), savedStateHandle = savedStateHandle, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt index 68e509623..3bda50b54 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt @@ -113,6 +113,7 @@ private val DEFAULT_MAP_VALUE: Map, Any> = mapOf( FlagKey.OnboardingFlow to true, FlagKey.ImportLoginsFlow to true, FlagKey.SshKeyCipherItems to true, + FlagKey.VerifiedSsoDomainEndpoint to true, ) private val UPDATED_MAP_VALUE: Map, Any> = mapOf( @@ -122,6 +123,7 @@ private val UPDATED_MAP_VALUE: Map, Any> = mapOf( FlagKey.OnboardingFlow to false, FlagKey.ImportLoginsFlow to false, FlagKey.SshKeyCipherItems to false, + FlagKey.VerifiedSsoDomainEndpoint to false, ) private val DEFAULT_STATE = DebugMenuState( From 8f9585e4bce338b7cdfff4e0695b8f29cc171feb Mon Sep 17 00:00:00 2001 From: Andrew Haisting <142518658+ahaisting-livefront@users.noreply.github.com> Date: Mon, 4 Nov 2024 08:24:36 -0600 Subject: [PATCH 2/9] Bump authenticatorbridge sdk version to 1.0.0 (#4221) --- app/build.gradle.kts | 3 +-- ... => authenticatorbridge-1.0.0-release.aar} | Bin 81461 -> 81465 bytes authenticatorbridge/CHANGELOG.md | 6 +++++- authenticatorbridge/build.gradle.kts | 4 ++-- 4 files changed, 8 insertions(+), 5 deletions(-) rename app/libs/{authenticatorbridge-0.1.0-SNAPSHOT-release.aar => authenticatorbridge-1.0.0-release.aar} (63%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c31e52086..37d2a0eec 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -162,8 +162,7 @@ dependencies { add("standardImplementation", dependencyNotation) } - // TODO: this should use a versioned AAR instead of referencing a local AAR BITAU-94 - implementation(files("libs/authenticatorbridge-0.1.0-SNAPSHOT-release.aar")) + implementation(files("libs/authenticatorbridge-1.0.0-release.aar")) implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) diff --git a/app/libs/authenticatorbridge-0.1.0-SNAPSHOT-release.aar b/app/libs/authenticatorbridge-1.0.0-release.aar similarity index 63% rename from app/libs/authenticatorbridge-0.1.0-SNAPSHOT-release.aar rename to app/libs/authenticatorbridge-1.0.0-release.aar index defcb5273d55ebf0ae25f1433082da3d6a2a0b4a..58a7c494b0dac217b5a7a4c5c098a538a777393c 100644 GIT binary patch delta 27460 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 TmNo$>m+w6RKn8?40RR91=+3d( delta 27461 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 Umry+cMVAjg0VD>1IRO9w0LlVj761SM diff --git a/authenticatorbridge/CHANGELOG.md b/authenticatorbridge/CHANGELOG.md index 62a9ddd44..fdca1b87c 100644 --- a/authenticatorbridge/CHANGELOG.md +++ b/authenticatorbridge/CHANGELOG.md @@ -1,4 +1,4 @@ -v0.1.0 (pending) +v1.1.0 (pending) -------- ### API Changes @@ -6,3 +6,7 @@ v0.1.0 (pending) ### Breaking Changes ### Bug Fixes + +v1.0.0 +-------- +Initial release. \ No newline at end of file diff --git a/authenticatorbridge/build.gradle.kts b/authenticatorbridge/build.gradle.kts index cadfb31db..cf4074b7c 100644 --- a/authenticatorbridge/build.gradle.kts +++ b/authenticatorbridge/build.gradle.kts @@ -1,7 +1,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget // For more info on versioning, see the README. -val version = "0.1.0" +val version = "1.0.0" plugins { alias(libs.plugins.android.library) @@ -46,7 +46,7 @@ android { outputs .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } .forEach { output -> - val outputFileName = "authenticatorbridge-${version}-SNAPSHOT-${variant.baseName}.aar" + val outputFileName = "authenticatorbridge-${version}-${variant.baseName}.aar" output.outputFileName = outputFileName } } From 202b4de5cabc3cc71304da47d040bd586d49a037 Mon Sep 17 00:00:00 2001 From: Dave Severns <149429124+dseverns-livefront@users.noreply.github.com> Date: Tue, 5 Nov 2024 10:29:05 -0500 Subject: [PATCH 3/9] PM-13848 Handle URIs with ports and host matching (#4203) --- .../data/platform/util/StringExtensions.kt | 15 +++- .../data/platform/util/URIExtensions.kt | 21 ++++++ .../data/util/StringExtensionsTest.kt | 70 +++++++++++++++++++ .../bitwarden/data/util/UriExtensionsTest.kt | 35 ++++++++++ 4 files changed, 138 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/StringExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/StringExtensions.kt index 909cb2ef5..cfb84dbb8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/StringExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/StringExtensions.kt @@ -56,6 +56,7 @@ fun String.getWebHostFromAndroidUriOrNull(): String? = fun String.getDomainOrNull(resourceCacheManager: ResourceCacheManager): String? = this .toUriOrNull() + ?.addSchemeToUriIfNecessary() ?.parseDomainOrNull(resourceCacheManager = resourceCacheManager) /** @@ -63,7 +64,10 @@ fun String.getDomainOrNull(resourceCacheManager: ResourceCacheManager): String? */ @OmitFromCoverage fun String.hasPort(): Boolean { - val uri = this.toUriOrNull() ?: return false + val uri = this + .toUriOrNull() + ?.addSchemeToUriIfNecessary() + ?: return false return uri.port != -1 } @@ -71,14 +75,19 @@ fun String.hasPort(): Boolean { * Extract the host from this [String] if possible, otherwise return null. */ @OmitFromCoverage -fun String.getHostOrNull(): String? = this.toUriOrNull()?.host +fun String.getHostOrNull(): String? = this.toUriOrNull() + ?.addSchemeToUriIfNecessary() + ?.host /** * Extract the host with optional port from this [String] if possible, otherwise return null. */ @OmitFromCoverage fun String.getHostWithPortOrNull(): String? { - val uri = this.toUriOrNull() ?: return null + val uri = this + .toUriOrNull() + ?.addSchemeToUriIfNecessary() + ?: return null return uri.host?.let { host -> val port = uri.port if (port != -1) { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/URIExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/URIExtensions.kt index 1d6647222..eb96af197 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/URIExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/URIExtensions.kt @@ -45,6 +45,27 @@ fun URI.parseDomainNameOrNull(resourceCacheManager: ResourceCacheManager): Domai ) } +/** + * Adds and HTTPS scheme to a valid URI if that URI has a valid host in the raw string but + * the standard parsing of `URI("foo.com")` is not able to determine a valid host. + * (i.e. val uri = URI("foo.bar:1090") -> uri.host == null) + */ +fun URI.addSchemeToUriIfNecessary(): URI { + val uriString = this.toString() + return if ( + // see if the string contains a host pattern + uriString.contains(".") && + // if it does but the URI's host is null, add an https scheme + this.host == null && + // provided that scheme does not exist already. + !uriString.hasHttpProtocol() + ) { + URI("https://$uriString") + } else { + this + } +} + /** * The internal implementation of [parseDomainNameOrNull]. This doesn't extend URI and has a * non-null [host] parameter. Technically, URI.host could be null and we want to avoid issues with diff --git a/app/src/test/java/com/x8bit/bitwarden/data/util/StringExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/util/StringExtensionsTest.kt index fa064d77c..b4055a0f4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/util/StringExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/util/StringExtensionsTest.kt @@ -3,8 +3,11 @@ package com.x8bit.bitwarden.data.util import com.x8bit.bitwarden.data.platform.manager.ResourceCacheManager import com.x8bit.bitwarden.data.platform.util.findLastSubstringIndicesOrNull import com.x8bit.bitwarden.data.platform.util.getDomainOrNull +import com.x8bit.bitwarden.data.platform.util.getHostOrNull +import com.x8bit.bitwarden.data.platform.util.getHostWithPortOrNull import com.x8bit.bitwarden.data.platform.util.getWebHostFromAndroidUriOrNull import com.x8bit.bitwarden.data.platform.util.hasHttpProtocol +import com.x8bit.bitwarden.data.platform.util.hasPort import com.x8bit.bitwarden.data.platform.util.isAndroidApp import com.x8bit.bitwarden.data.platform.util.parseDomainOrNull import com.x8bit.bitwarden.data.platform.util.toUriOrNull @@ -154,4 +157,71 @@ class StringExtensionsTest { // Verify assertEquals(expected, actual) } + + @Test + fun `getHostOrNull should return host when one is present`() { + val expectedHost = "www.google.com" + assertEquals(expectedHost, expectedHost.getHostOrNull()) + } + + @Test + fun `getHostOrNull should return null when no host is present`() { + assertNull("boo".getHostOrNull()) + } + + @Test + fun `getHostOrNull should return host from URI string when present and custom URI scheme`() { + val expectedHost = "www.google.com" + val hostWithScheme = "androidapp://$expectedHost" + assertEquals(expectedHost, hostWithScheme.getHostOrNull()) + } + + @Suppress("MaxLineLength") + @Test + fun `getHostOrNull should return host from URI string when present and has port but no scheme`() { + val expectedHost = "www.google.com" + val hostWithPort = "$expectedHost:8080" + assertEquals(expectedHost, hostWithPort.getHostOrNull()) + } + + @Test + fun `hasPort returns true when port is present`() { + val uriString = "www.google.com:8080" + assertTrue("www.google.com:8080".hasPort()) + } + + @Test + fun `hasPort returns false when port is not present`() { + assertFalse("www.google.com".hasPort()) + } + + @Test + fun `hasPort return true when port is present and custom scheme is present`() { + val uriString = "androidapp://www.google.com:8080" + assertTrue(uriString.hasPort()) + } + + @Test + fun `getHostWithPortOrNull should return host with port when present`() { + val uriString = "www.google.com:8080" + assertEquals("www.google.com:8080", uriString.getHostWithPortOrNull()) + } + + @Test + fun `getHostWithPortOrNull should return host when no port is present`() { + val uriString = "www.google.com" + assertEquals("www.google.com", uriString.getHostWithPortOrNull()) + } + + @Test + fun `getHostWithPortOrNull should return null when no host is present`() { + assertNull("boo".getHostWithPortOrNull()) + } + + @Suppress("MaxLineLength") + @Test + fun `getHostWithPortOrNull should return host with port when present and custom scheme is present`() { + val uriString = "androidapp://www.google.com:8080" + assertEquals("www.google.com:8080", uriString.getHostWithPortOrNull()) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/util/UriExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/util/UriExtensionsTest.kt index 0c5090014..deaf3baf6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/util/UriExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/util/UriExtensionsTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.util import com.x8bit.bitwarden.data.platform.manager.ResourceCacheManager import com.x8bit.bitwarden.data.platform.manager.model.DomainName +import com.x8bit.bitwarden.data.platform.util.addSchemeToUriIfNecessary import com.x8bit.bitwarden.data.platform.util.parseDomainNameOrNull import com.x8bit.bitwarden.data.platform.util.parseDomainOrNull import io.mockk.every @@ -321,4 +322,38 @@ class UriExtensionsTest { assertEquals(expected[index], actual) } } + + @Test + fun `addSchemeToUriIfNecessary should add https when missing`() { + val uriWithNoScheme = URI("example.com") + assertEquals(URI("https://example.com"), uriWithNoScheme.addSchemeToUriIfNecessary()) + } + + @Suppress("MaxLineLength") + @Test + fun `addSchemeToUriIfNecessary should add https when https scheme is missing and a port is present`() { + val uriWithPort = URI("example.com:8080") + assertEquals(URI("https://example.com:8080"), uriWithPort.addSchemeToUriIfNecessary()) + } + + @Test + fun `addSchemeToUriIfNecessary should not add https when http scheme is present`() { + val uriWithHttpScheme = URI("http://example.com") + assertEquals(URI("http://example.com"), uriWithHttpScheme.addSchemeToUriIfNecessary()) + } + + @Test + fun `addSchemeToUriIfNecessary should not add https when https scheme is present`() { + val uriWithHttpsScheme = URI("https://example.com") + assertEquals(URI("https://example.com"), uriWithHttpsScheme.addSchemeToUriIfNecessary()) + } + + @Test + fun `addSchemeToUriIfNecessary should not add https when custom scheme is already present`() { + val uriWithCustomScheme = URI("bitwarden://example.com") + assertEquals( + URI("bitwarden://example.com"), + uriWithCustomScheme.addSchemeToUriIfNecessary(), + ) + } } From 4930c1032e0ceb21b1e41afb5aba99ce64410908 Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 5 Nov 2024 11:16:58 -0600 Subject: [PATCH 4/9] PM-14458: Update notifications permissions request (#4229) --- .../bottomsheet/BitwardenModalBottomSheet.kt | 28 ++++- .../pendingrequests/PendingRequestsScreen.kt | 100 ++++++++++++++++++ .../PendingRequestsViewModel.kt | 13 +++ .../importlogins/ImportLoginsScreen.kt | 20 +--- .../ui/vault/feature/vault/VaultScreen.kt | 32 ------ .../ui/vault/feature/vault/VaultViewModel.kt | 5 - app/src/main/res/drawable-night/img_2fa.xml | 73 +++++++++++++ app/src/main/res/drawable/img_2fa.xml | 73 +++++++++++++ app/src/main/res/values/strings.xml | 4 + .../PendingRequestsScreenTest.kt | 73 +++++++++++-- .../PendingRequestsViewModelTest.kt | 24 +++-- .../ui/vault/feature/vault/VaultScreenTest.kt | 12 --- .../vault/feature/vault/VaultViewModelTest.kt | 1 - 13 files changed, 369 insertions(+), 89 deletions(-) create mode 100644 app/src/main/res/drawable-night/img_2fa.xml create mode 100644 app/src/main/res/drawable/img_2fa.xml diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/bottomsheet/BitwardenModalBottomSheet.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/bottomsheet/BitwardenModalBottomSheet.kt index 505b6b445..85956def3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/bottomsheet/BitwardenModalBottomSheet.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/bottomsheet/BitwardenModalBottomSheet.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.SheetState import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource @@ -19,6 +20,7 @@ import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme +import kotlinx.coroutines.launch /** * A reusable modal bottom sheet that applies provides a bottom sheet layout with the @@ -28,11 +30,12 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme * @param sheetTitle The title to display in the [BitwardenTopAppBar] * @param onDismiss The action to perform when the bottom sheet is dismissed will also be performed * when the "close" icon is clicked, caller must handle any desired animation or hiding of the - * bottom sheet. + * bottom sheet. This will be invoked _after_ the sheet has been animated away. * @param showBottomSheet Whether or not to show the bottom sheet, by default this is true assuming * the showing/hiding will be handled by the caller. * @param sheetContent Content to display in the bottom sheet. The content is passed the padding - * from the containing [BitwardenScaffold]. + * from the containing [BitwardenScaffold] and a `onDismiss` lambda to be used for manual dismissal + * that will include the dismissal animation. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -42,7 +45,10 @@ fun BitwardenModalBottomSheet( modifier: Modifier = Modifier, showBottomSheet: Boolean = true, sheetState: SheetState = rememberModalBottomSheetState(), - sheetContent: @Composable (PaddingValues) -> Unit, + sheetContent: @Composable ( + paddingValues: PaddingValues, + animatedOnDismiss: () -> Unit, + ) -> Unit, ) { if (!showBottomSheet) return ModalBottomSheet( @@ -56,13 +62,14 @@ fun BitwardenModalBottomSheet( shape = BitwardenTheme.shapes.bottomSheet, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val animatedOnDismiss = sheetState.createAnimatedDismissAction(onDismiss = onDismiss) BitwardenScaffold( topBar = { BitwardenTopAppBar( title = sheetTitle, navigationIcon = NavigationIcon( navigationIcon = rememberVectorPainter(R.drawable.ic_close), - onNavigationIconClick = onDismiss, + onNavigationIconClick = animatedOnDismiss, navigationIconContentDescription = stringResource(R.string.close), ), scrollBehavior = scrollBehavior, @@ -73,7 +80,18 @@ fun BitwardenModalBottomSheet( .nestedScroll(scrollBehavior.nestedScrollConnection) .fillMaxSize(), ) { paddingValues -> - sheetContent(paddingValues) + sheetContent(paddingValues, animatedOnDismiss) } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SheetState.createAnimatedDismissAction(onDismiss: () -> Unit): () -> Unit { + val scope = rememberCoroutineScope() + return { + scope + .launch { this@createAnimatedDismissAction.hide() } + .invokeOnCompletion { onDismiss() } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt index 52d58190e..f38b56f25 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt @@ -1,5 +1,8 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests +import android.Manifest +import android.annotation.SuppressLint +import android.os.Build import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -14,6 +17,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -21,6 +25,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.ripple import androidx.compose.runtime.Composable @@ -40,11 +45,15 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow +import com.x8bit.bitwarden.data.platform.util.isFdroid import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect import com.x8bit.bitwarden.ui.platform.base.util.bottomDivider import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.bottomsheet.BitwardenModalBottomSheet +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent @@ -52,6 +61,8 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialo import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scaffold.rememberBitwardenPullToRefreshState import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.composition.LocalPermissionsManager +import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme /** @@ -62,6 +73,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme @Composable fun PendingRequestsScreen( viewModel: PendingRequestsViewModel = hiltViewModel(), + permissionsManager: PermissionsManager = LocalPermissionsManager.current, onNavigateBack: () -> Unit, onNavigateToLoginApproval: (fingerprint: String) -> Unit, ) { @@ -98,6 +110,29 @@ fun PendingRequestsScreen( } } + val hideBottomSheet = state.hideBottomSheet || + isFdroid || + isBuildVersionBelow(Build.VERSION_CODES.TIRAMISU) || + permissionsManager.checkPermission(Manifest.permission.POST_NOTIFICATIONS) || + !permissionsManager.shouldShowRequestPermissionRationale( + permission = Manifest.permission.POST_NOTIFICATIONS, + ) + BitwardenModalBottomSheet( + showBottomSheet = !hideBottomSheet, + sheetTitle = stringResource(R.string.enable_notifications), + onDismiss = remember(viewModel) { + { viewModel.trySendAction(PendingRequestsAction.HideBottomSheet) } + }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + modifier = Modifier.statusBarsPadding(), + ) { paddingValues, animatedOnDismiss -> + PendingRequestsBottomSheetContent( + modifier = Modifier.padding(paddingValues), + permissionsManager = permissionsManager, + onDismiss = animatedOnDismiss, + ) + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( modifier = Modifier @@ -338,3 +373,68 @@ private fun PendingRequestsEmpty( Spacer(modifier = Modifier.height(64.dp)) } } + +@Composable +private fun PendingRequestsBottomSheetContent( + permissionsManager: PermissionsManager, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val notificationPermissionLauncher = permissionsManager.getLauncher { + onDismiss() + } + Column(modifier = modifier.verticalScroll(rememberScrollState())) { + Spacer(modifier = Modifier.height(height = 24.dp)) + Image( + painter = rememberVectorPainter(id = R.drawable.img_2fa), + contentDescription = null, + modifier = Modifier + .standardHorizontalMargin() + .size(size = 132.dp) + .align(alignment = Alignment.CenterHorizontally), + ) + Spacer(modifier = Modifier.height(height = 24.dp)) + Text( + text = stringResource(id = R.string.log_in_quickly_and_easily_across_devices), + style = BitwardenTheme.typography.titleMedium, + color = BitwardenTheme.colorScheme.text.primary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(height = 12.dp)) + @Suppress("MaxLineLength") + Text( + text = stringResource( + id = R.string.bitwarden_can_notify_you_each_time_you_receive_a_new_login_request_from_another_device, + ), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.primary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(height = 24.dp)) + BitwardenFilledButton( + label = stringResource(id = R.string.enable_notifications), + onClick = { + @SuppressLint("InlinedApi") + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + }, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(height = 12.dp)) + BitwardenOutlinedButton( + label = stringResource(id = R.string.skip_for_now), + onClick = onDismiss, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt index f4ac92d24..812b952eb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt @@ -27,6 +27,7 @@ private const val KEY_STATE = "state" /** * View model for the pending login requests screen. */ +@Suppress("TooManyFunctions") @HiltViewModel class PendingRequestsViewModel @Inject constructor( private val clock: Clock, @@ -39,6 +40,7 @@ class PendingRequestsViewModel @Inject constructor( viewState = PendingRequestsState.ViewState.Loading, isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value, isRefreshing = false, + hideBottomSheet = false, ), ) { private var authJob: Job = Job().apply { complete() } @@ -56,6 +58,7 @@ class PendingRequestsViewModel @Inject constructor( when (action) { PendingRequestsAction.CloseClick -> handleCloseClicked() PendingRequestsAction.DeclineAllRequestsConfirm -> handleDeclineAllRequestsConfirmed() + PendingRequestsAction.HideBottomSheet -> handleHideBottomSheet() PendingRequestsAction.LifecycleResume -> handleOnLifecycleResumed() PendingRequestsAction.RefreshPull -> handleRefreshPull() is PendingRequestsAction.PendingRequestRowClick -> { @@ -89,6 +92,10 @@ class PendingRequestsViewModel @Inject constructor( } } + private fun handleHideBottomSheet() { + mutableStateFlow.update { it.copy(hideBottomSheet = true) } + } + private fun handleOnLifecycleResumed() { updateAuthRequestList() } @@ -193,6 +200,7 @@ data class PendingRequestsState( val viewState: ViewState, private val isPullToRefreshSettingEnabled: Boolean, val isRefreshing: Boolean, + val hideBottomSheet: Boolean, ) : Parcelable { /** * Indicates that the pull-to-refresh should be enabled in the UI. @@ -297,6 +305,11 @@ sealed class PendingRequestsAction { */ data object DeclineAllRequestsConfirm : PendingRequestsAction() + /** + * The user has dismissed the bottom sheet. + */ + data object HideBottomSheet : PendingRequestsAction() + /** * The screen has been re-opened and should be updated. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreen.kt index 4afe42fa8..e41ddeae4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -65,7 +64,6 @@ import com.x8bit.bitwarden.ui.vault.feature.importlogins.handlers.ImportLoginHan import com.x8bit.bitwarden.ui.vault.feature.importlogins.handlers.rememberImportLoginHandler import com.x8bit.bitwarden.ui.vault.feature.importlogins.model.InstructionStep import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.launch private const val IMPORT_HELP_URL = "https://bitwarden.com/help/import-data/" @@ -100,27 +98,15 @@ fun ImportLoginsScreen( } } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val hideSheetAndExecuteCompleteImportLogins: () -> Unit = { - // This pattern mirrors the onDismissRequest handling in the material ModalBottomSheet - scope - .launch { - sheetState.hide() - } - .invokeOnCompletion { - handler.onSuccessfulSyncAcknowledged() - } - } BitwardenModalBottomSheet( showBottomSheet = state.showBottomSheet, sheetTitle = stringResource(R.string.bitwarden_tools), - onDismiss = hideSheetAndExecuteCompleteImportLogins, + onDismiss = handler.onSuccessfulSyncAcknowledged, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), modifier = Modifier.statusBarsPadding(), - ) { paddingValues -> + ) { paddingValues, animatedOnDismiss -> ImportLoginsSuccessBottomSheetContent( - onCompleteImportLogins = hideSheetAndExecuteCompleteImportLogins, + onCompleteImportLogins = animatedOnDismiss, modifier = Modifier.padding(paddingValues), ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index 0bbded82d..04f3c078a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -1,7 +1,5 @@ package com.x8bit.bitwarden.ui.vault.feature.vault -import android.Manifest -import android.annotation.SuppressLint import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.scaleIn @@ -15,7 +13,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -61,11 +58,9 @@ import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnac import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager -import com.x8bit.bitwarden.ui.platform.composition.LocalPermissionsManager import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager -import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers @@ -90,7 +85,6 @@ fun VaultScreen( onNavigateToImportLogins: (SnackbarRelay) -> Unit, exitManager: ExitManager = LocalExitManager.current, intentManager: IntentManager = LocalIntentManager.current, - permissionsManager: PermissionsManager = LocalPermissionsManager.current, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current @@ -137,10 +131,6 @@ fun VaultScreen( } } val vaultHandlers = remember(viewModel) { VaultHandlers.create(viewModel) } - VaultScreenPushNotifications( - hideNotificationsDialog = state.hideNotificationsDialog, - permissionsManager = permissionsManager, - ) VaultScreenScaffold( state = state, pullToRefreshState = pullToRefreshState, @@ -150,28 +140,6 @@ fun VaultScreen( ) } -/** - * Handles the notifications permission request. - */ -@Composable -private fun VaultScreenPushNotifications( - hideNotificationsDialog: Boolean, - permissionsManager: PermissionsManager, -) { - if (hideNotificationsDialog) return - val launcher = permissionsManager.getLauncher { - // We do not actually care what the response is, we just need - // to give the user a chance to give us the permission. - } - LaunchedEffect(key1 = Unit) { - @SuppressLint("InlinedApi") - // We check the version code as part of the 'hideNotificationsDialog' property. - if (!permissionsManager.checkPermission(Manifest.permission.POST_NOTIFICATIONS)) { - launcher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } -} - /** * Scaffold for the [VaultScreen] */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 879701c60..99b7e710c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -1,6 +1,5 @@ package com.x8bit.bitwarden.ui.vault.feature.vault -import android.os.Build import android.os.Parcelable import androidx.compose.ui.graphics.Color import androidx.lifecycle.viewModelScope @@ -19,8 +18,6 @@ import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl -import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow -import com.x8bit.bitwarden.data.platform.util.isFdroid import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult @@ -102,7 +99,6 @@ class VaultViewModel @Inject constructor( isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value, baseIconUrl = userState.activeAccount.environment.environmentUrlData.baseIconUrl, hasMasterPassword = userState.activeAccount.hasMasterPassword, - hideNotificationsDialog = isBuildVersionBelow(Build.VERSION_CODES.TIRAMISU) || isFdroid, isRefreshing = false, showImportActionCard = false, showSshKeys = showSshKeys, @@ -713,7 +709,6 @@ data class VaultState( private val isPullToRefreshSettingEnabled: Boolean, val baseIconUrl: String, val isIconLoadingDisabled: Boolean, - val hideNotificationsDialog: Boolean, val isRefreshing: Boolean, val showImportActionCard: Boolean, val showSshKeys: Boolean, diff --git a/app/src/main/res/drawable-night/img_2fa.xml b/app/src/main/res/drawable-night/img_2fa.xml new file mode 100644 index 000000000..2f2753f58 --- /dev/null +++ b/app/src/main/res/drawable-night/img_2fa.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_2fa.xml b/app/src/main/res/drawable/img_2fa.xml new file mode 100644 index 000000000..735a3c4a7 --- /dev/null +++ b/app/src/main/res/drawable/img_2fa.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8949e5404..efd7faf15 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1082,4 +1082,8 @@ Do you want to switch to this account? SSH keys Copy public key Copy fingerprint + Enable notifications + Log in quickly and easily across devices + Bitwarden can notify you each time you receive a new login request from another device. + Skip for now diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt index 6e373b534..2311cc0b2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt @@ -1,18 +1,33 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.assert +import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performSemanticsAction import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow +import com.x8bit.bitwarden.data.platform.util.isFdroid +import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager import com.x8bit.bitwarden.ui.util.assertNoDialogExists import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.jupiter.api.Assertions.assertTrue @@ -24,22 +39,38 @@ class PendingRequestsScreenTest : BaseComposeTest() { private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) - private val viewModel = mockk(relaxed = true) { + private val viewModel = mockk { every { eventFlow } returns mutableEventFlow every { stateFlow } returns mutableStateFlow + every { trySendAction(any()) } just runs + } + private val permissionsManager = FakePermissionManager().apply { + checkPermissionResult = false + shouldShowRequestRationale = true } @Before fun setUp() { + mockkStatic(::isFdroid) + mockkStatic(::isBuildVersionBelow) + every { isFdroid } returns false + every { isBuildVersionBelow(any()) } returns false composeTestRule.setContent { PendingRequestsScreen( onNavigateBack = { onNavigateBackCalled = true }, onNavigateToLoginApproval = { _ -> onNavigateToLoginApprovalCalled = true }, viewModel = viewModel, + permissionsManager = permissionsManager, ) } } + @After + fun tearDown() { + unmockkStatic(::isFdroid) + unmockkStatic(::isBuildVersionBelow) + } + @Test fun `on NavigateBack should call onNavigateBack`() { mutableEventFlow.tryEmit(PendingRequestsEvent.NavigateBack) @@ -70,6 +101,7 @@ class PendingRequestsScreenTest : BaseComposeTest() { ), ), ), + hideBottomSheet = true, ) composeTestRule.onNodeWithText("Decline all requests").performClick() composeTestRule @@ -101,6 +133,7 @@ class PendingRequestsScreenTest : BaseComposeTest() { ), ), ), + hideBottomSheet = true, ) composeTestRule.onNodeWithText("Decline all requests").performClick() composeTestRule @@ -114,12 +147,36 @@ class PendingRequestsScreenTest : BaseComposeTest() { } } - companion object { - val DEFAULT_STATE: PendingRequestsState = PendingRequestsState( - authRequests = emptyList(), - viewState = PendingRequestsState.ViewState.Loading, - isPullToRefreshSettingEnabled = false, - isRefreshing = false, - ) + @Test + fun `on skip for now click should emit HideBottomSheet`() { + composeTestRule + .onNodeWithText(text = "Skip for now") + .performScrollTo() + .performSemanticsAction(SemanticsActions.OnClick) + dispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 1000L) + verify(exactly = 1) { + viewModel.trySendAction(PendingRequestsAction.HideBottomSheet) + } + } + + @Test + fun `on Enable notifications click should emit HideBottomSheet`() { + composeTestRule + .onAllNodesWithText(text = "Enable notifications") + .filterToOne(hasClickAction()) + .performScrollTo() + .performSemanticsAction(SemanticsActions.OnClick) + dispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 1000L) + verify(exactly = 1) { + viewModel.trySendAction(PendingRequestsAction.HideBottomSheet) + } } } + +private val DEFAULT_STATE: PendingRequestsState = PendingRequestsState( + authRequests = emptyList(), + viewState = PendingRequestsState.ViewState.Loading, + isPullToRefreshSettingEnabled = false, + isRefreshing = false, + hideBottomSheet = false, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt index bdea2ce7c..1637f4803 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt @@ -165,6 +165,13 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { } } + @Test + fun `on HideBottomSheet should make hideBottomSheet true`() { + val viewModel = createViewModel() + viewModel.trySendAction(PendingRequestsAction.HideBottomSheet) + assertEquals(DEFAULT_STATE.copy(hideBottomSheet = true), viewModel.stateFlow.value) + } + @Test fun `on RefreshPull should make auth request`() = runTest { val viewModel = createViewModel() @@ -370,13 +377,12 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { settingsRepository = settingsRepository, savedStateHandle = SavedStateHandle().apply { set("state", state) }, ) - - companion object { - val DEFAULT_STATE: PendingRequestsState = PendingRequestsState( - authRequests = emptyList(), - viewState = PendingRequestsState.ViewState.Empty, - isPullToRefreshSettingEnabled = false, - isRefreshing = false, - ) - } } + +private val DEFAULT_STATE: PendingRequestsState = PendingRequestsState( + authRequests = emptyList(), + viewState = PendingRequestsState.ViewState.Empty, + isPullToRefreshSettingEnabled = false, + isRefreshing = false, + hideBottomSheet = false, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 203f9a8fe..ef752981f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -29,7 +29,6 @@ import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager -import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed @@ -74,7 +73,6 @@ class VaultScreenTest : BaseComposeTest() { private var onNavigateToSearchScreen = false private val exitManager = mockk(relaxed = true) private val intentManager = mockk(relaxed = true) - private val permissionsManager = FakePermissionManager() private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) @@ -101,7 +99,6 @@ class VaultScreenTest : BaseComposeTest() { }, exitManager = exitManager, intentManager = intentManager, - permissionsManager = permissionsManager, ) } } @@ -1143,14 +1140,6 @@ class VaultScreenTest : BaseComposeTest() { } } - @Test - fun `permissionManager is invoked for notifications based on state`() { - assertFalse(permissionsManager.hasGetLauncherBeenCalled) - mutableStateFlow.update { it.copy(hideNotificationsDialog = false) } - composeTestRule.waitForIdle() - assertTrue(permissionsManager.hasGetLauncherBeenCalled) - } - @Test fun `action card for importing logins should show based on state`() { mutableStateFlow.update { @@ -1324,7 +1313,6 @@ private val DEFAULT_STATE: VaultState = VaultState( baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, isIconLoadingDisabled = false, hasMasterPassword = true, - hideNotificationsDialog = true, isRefreshing = false, showImportActionCard = false, showSshKeys = false, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index d3071498d..fc86a0cf2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -1930,7 +1930,6 @@ private fun createMockVaultState( baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, isIconLoadingDisabled = false, hasMasterPassword = true, - hideNotificationsDialog = true, showImportActionCard = true, isRefreshing = false, showSshKeys = showSshKeys, From db3490f61a6daf0c2f661a69115183fd5e71026f Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 5 Nov 2024 11:36:10 -0600 Subject: [PATCH 5/9] PM-14480: Update IntentManager to be able to launch apps (#4233) --- .../manager/intent/IntentManagerImpl.kt | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt index e53088032..3902676e6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt @@ -6,6 +6,7 @@ import android.content.ActivityNotFoundException import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.IntentSender import android.content.pm.PackageManager import android.net.Uri import android.os.Build @@ -27,6 +28,7 @@ import com.x8bit.bitwarden.MainActivity import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.autofill.util.toPendingIntentMutabilityFlag import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern import java.io.File import java.time.Clock @@ -82,7 +84,7 @@ class IntentManagerImpl( override fun startActivity(intent: Intent) { try { context.startActivity(intent) - } catch (e: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { // no-op } } @@ -115,7 +117,7 @@ class IntentManagerImpl( } context.startActivity(intent) true - } catch (e: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { false } @@ -132,12 +134,28 @@ class IntentManagerImpl( } override fun launchUri(uri: Uri) { - val newUri = if (uri.scheme == null) { - uri.buildUpon().scheme("https").build() + if (uri.scheme.equals(other = "androidapp", ignoreCase = true)) { + val packageName = uri.toString().removePrefix(prefix = "androidapp://") + if (isBuildVersionBelow(Build.VERSION_CODES.TIRAMISU)) { + startActivity(createPlayStoreIntent(packageName)) + } else { + try { + context + .packageManager + .getLaunchIntentSenderForPackage(packageName) + .sendIntent(context, Activity.RESULT_OK, null, null, null) + } catch (_: IntentSender.SendIntentException) { + startActivity(createPlayStoreIntent(packageName)) + } + } } else { - uri.normalizeScheme() + val newUri = if (uri.scheme == null) { + uri.buildUpon().scheme("https").build() + } else { + uri.normalizeScheme() + } + startActivity(Intent(Intent.ACTION_VIEW, newUri)) } - startActivity(Intent(Intent.ACTION_VIEW, newUri)) } override fun shareText(text: String) { @@ -301,6 +319,15 @@ class IntentManagerImpl( startActivity(intent) } + private fun createPlayStoreIntent(packageName: String): Intent { + val playStoreUri = "https://play.google.com/store/apps/details" + .toUri() + .buildUpon() + .appendQueryParameter("id", packageName) + .build() + return Intent(Intent.ACTION_VIEW, playStoreUri) + } + private fun getCameraFileData(): IntentManager.FileData { val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR) val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME) From 88a741c93a150f5b3c6814bad10ac72a70e36d69 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 Nov 2024 17:26:48 +0000 Subject: [PATCH 6/9] Autosync Crowdin Translations (#4217) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> --- app/src/main/res/values-af-rZA/strings.xml | 16 +++++-- app/src/main/res/values-ar-rSA/strings.xml | 16 +++++-- app/src/main/res/values-az-rAZ/strings.xml | 12 ++++- app/src/main/res/values-be-rBY/strings.xml | 16 +++++-- app/src/main/res/values-bg-rBG/strings.xml | 32 ++++++++----- app/src/main/res/values-bn-rBD/strings.xml | 16 +++++-- app/src/main/res/values-bs-rBA/strings.xml | 16 +++++-- app/src/main/res/values-ca-rES/strings.xml | 16 +++++-- app/src/main/res/values-cs-rCZ/strings.xml | 14 ++++-- app/src/main/res/values-cy-rGB/strings.xml | 16 +++++-- app/src/main/res/values-da-rDK/strings.xml | 10 +++- app/src/main/res/values-de-rDE/strings.xml | 10 +++- app/src/main/res/values-el-rGR/strings.xml | 14 ++++-- app/src/main/res/values-en-rGB/strings.xml | 10 +++- app/src/main/res/values-en-rIN/strings.xml | 10 +++- app/src/main/res/values-es-rES/strings.xml | 16 +++++-- app/src/main/res/values-et-rEE/strings.xml | 16 +++++-- app/src/main/res/values-eu-rES/strings.xml | 16 +++++-- app/src/main/res/values-fa-rIR/strings.xml | 16 +++++-- app/src/main/res/values-fi-rFI/strings.xml | 34 ++++++++------ app/src/main/res/values-fil-rPH/strings.xml | 16 +++++-- app/src/main/res/values-fr-rFR/strings.xml | 16 +++++-- app/src/main/res/values-gl-rES/strings.xml | 16 +++++-- app/src/main/res/values-hi-rIN/strings.xml | 16 +++++-- app/src/main/res/values-hr-rHR/strings.xml | 14 ++++-- app/src/main/res/values-hu-rHU/strings.xml | 10 +++- app/src/main/res/values-in-rID/strings.xml | 16 +++++-- app/src/main/res/values-it-rIT/strings.xml | 14 ++++-- app/src/main/res/values-iw-rIL/strings.xml | 16 +++++-- app/src/main/res/values-ja-rJP/strings.xml | 14 ++++-- app/src/main/res/values-ka-rGE/strings.xml | 16 +++++-- app/src/main/res/values-kn-rIN/strings.xml | 16 +++++-- app/src/main/res/values-ko-rKR/strings.xml | 16 +++++-- app/src/main/res/values-lt-rLT/strings.xml | 16 +++++-- app/src/main/res/values-lv-rLV/strings.xml | 10 +++- app/src/main/res/values-ml-rIN/strings.xml | 16 +++++-- app/src/main/res/values-mr-rIN/strings.xml | 16 +++++-- app/src/main/res/values-my-rMM/strings.xml | 16 +++++-- app/src/main/res/values-nb-rNO/strings.xml | 16 +++++-- app/src/main/res/values-ne-rNP/strings.xml | 16 +++++-- app/src/main/res/values-nl-rNL/strings.xml | 10 +++- app/src/main/res/values-nn-rNO/strings.xml | 16 +++++-- app/src/main/res/values-or-rIN/strings.xml | 16 +++++-- app/src/main/res/values-pl-rPL/strings.xml | 16 +++++-- app/src/main/res/values-pt-rBR/strings.xml | 12 ++++- app/src/main/res/values-pt-rPT/strings.xml | 10 +++- app/src/main/res/values-ro-rRO/strings.xml | 16 +++++-- app/src/main/res/values-ru-rRU/strings.xml | 34 ++++++++------ app/src/main/res/values-si-rLK/strings.xml | 16 +++++-- app/src/main/res/values-sk-rSK/strings.xml | 10 +++- app/src/main/res/values-sl-rSI/strings.xml | 16 +++++-- app/src/main/res/values-sr-rSP/strings.xml | 12 ++++- app/src/main/res/values-sv-rSE/strings.xml | 38 +++++++++------ app/src/main/res/values-ta-rIN/strings.xml | 52 ++++++++++++--------- app/src/main/res/values-te-rIN/strings.xml | 16 +++++-- app/src/main/res/values-th-rTH/strings.xml | 16 +++++-- app/src/main/res/values-tr-rTR/strings.xml | 16 +++++-- app/src/main/res/values-uk-rUA/strings.xml | 34 ++++++++------ app/src/main/res/values-vi-rVN/strings.xml | 32 ++++++++----- app/src/main/res/values-zh-rCN/strings.xml | 10 +++- app/src/main/res/values-zh-rTW/strings.xml | 16 +++++-- 61 files changed, 763 insertions(+), 275 deletions(-) diff --git a/app/src/main/res/values-af-rZA/strings.xml b/app/src/main/res/values-af-rZA/strings.xml index 15fd6dcbb..e7e5b03df 100644 --- a/app/src/main/res/values-af-rZA/strings.xml +++ b/app/src/main/res/values-af-rZA/strings.xml @@ -1001,7 +1001,7 @@ Wil u na die rekening omskakel? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Wil u na die rekening omskakel? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Wil u na die rekening omskakel? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Wil u na die rekening omskakel? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-ar-rSA/strings.xml b/app/src/main/res/values-ar-rSA/strings.xml index 9498417d4..74a19059c 100644 --- a/app/src/main/res/values-ar-rSA/strings.xml +++ b/app/src/main/res/values-ar-rSA/strings.xml @@ -1001,7 +1001,7 @@ Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-az-rAZ/strings.xml b/app/src/main/res/values-az-rAZ/strings.xml index 6d8a6eab0..36f55ab01 100644 --- a/app/src/main/res/values-az-rAZ/strings.xml +++ b/app/src/main/res/values-az-rAZ/strings.xml @@ -1054,7 +1054,7 @@ Bu hesaba keçmək istəyirsiniz? \"Hazırdır\"a toxunun Təhlükəsizliyiniz üçün saxlanılmış parol faylınızı sildiyinizə əmin olun. saxlanılmış parol faylınızı silin. - Kömək lazımdır? Daxilə köçürmə üzrə kömək səhifəmizə baxın. + Kömək lazımdır? Daxilə köçürmə üzrə kömək səhifəmizə baxın. daxilə köçürmə üzrə kömək Xaricə köçürdüyünüz faylı kompüterinizdə rahatlıqla tapa biləcəyiniz yerdə saxlayın. Xaricə köçürülən faylı saxlayın @@ -1071,5 +1071,13 @@ Bu hesaba keçmək istəyirsiniz? Bitwarden-in veb və masaüstü alətləri ilə istənilən yerdən girişlərinizi idarə edin. Bitwarden Alətləri Anladım - No logins were imported + Heç bir giriş daxilə köçürülmədi + Girişlər daxilə köçürüldü + Kompüterinizdən daxilə köçürdüyünüz parol faylını silməyi unutmayın + SSH açarı + Public açar + Private açar + SSH açarları + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 974ea4585..64ba828fd 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -1000,7 +1000,7 @@ Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1039,8 +1039,8 @@ Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1054,7 +1054,7 @@ then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1072,4 +1072,12 @@ Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-bg-rBG/strings.xml b/app/src/main/res/values-bg-rBG/strings.xml index 0bcd52fdf..bfd48a34d 100644 --- a/app/src/main/res/values-bg-rBG/strings.xml +++ b/app/src/main/res/values-bg-rBG/strings.xml @@ -1055,22 +1055,30 @@ а след това натиснете Готово За по-сигурно изтрийте файла със запазените пароли. изтрийте файла със запазените пароли. - Имате ли нужда от помощ? Прегледайте помощта относно внасянето. + Имате ли нужда от помощ? Прегледайте помощта относно внасянето. помощта относно внасянето Запазете изнесения файл някъде в компютъра си, така че да може да го намерите лесно. Запазете изнесения файл Това не изглежда да е познат сървър на Битуорден. Може да се наложи да го проверите при доставчика си или да обновите сървъра си. Синхронизиране на елементите за вписване… SSH Key Cipher Item Types - Download the browser extension - Go to bitwarden.com/download to integrate Bitwarden into your favorite browser for a seamless experience. - Use the web app - Log in at bitwarden.com to easily manage your account and update settings. - Autofill passwords - Set up autofill on all your devices to login with a single tap anywhere. - Import Successful! - Manage your logins from anywhere with Bitwarden tools for web and desktop. - Bitwarden Tools - Got it - No logins were imported + Свалете добавката за браузър + Посетете bitwarden.com/download, за да вградите Битуорден в любимия си браузър и да използвате възможностите му по-лесно. + Използвайте приложението по уеб + Впишете се в bitwarden.com, където можете лесно да управлявате регистрацията си и да променяте настройки. + Автоматично попълване на пароли + Настройте автоматичното попълвана на всички свои устройства, за да се вписвате навсякъде с едно докосване. + Внасянето е успешно! + Управлявайте данните си за вписване, където и да се намирате, с инструментите на Битуорден налични в уеб и настолното приложение. + Инструменти на Битуорден + Разбрано + Не бяха внесени никакви елементи за вписване + Данните за вписване са внесени + Не забравяйте да изтриете файла с паролите за внасяне от компютъра си + SSH ключ + Публичен ключ + Частен ключ + SSH ключове + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index 558ed5852..a8436edc9 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-bs-rBA/strings.xml b/app/src/main/res/values-bs-rBA/strings.xml index 803e63b75..dee39d9fb 100644 --- a/app/src/main/res/values-bs-rBA/strings.xml +++ b/app/src/main/res/values-bs-rBA/strings.xml @@ -1000,7 +1000,7 @@ Skeniranje će biti izvršeno automatski. Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1039,8 +1039,8 @@ Skeniranje će biti izvršeno automatski. Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1054,7 +1054,7 @@ Skeniranje će biti izvršeno automatski. then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1072,4 +1072,12 @@ Skeniranje će biti izvršeno automatski. Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-ca-rES/strings.xml b/app/src/main/res/values-ca-rES/strings.xml index c57afd1ad..10bbf6901 100644 --- a/app/src/main/res/values-ca-rES/strings.xml +++ b/app/src/main/res/values-ca-rES/strings.xml @@ -1001,7 +1001,7 @@ Voleu canviar a aquest compte? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Voleu canviar a aquest compte? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Voleu canviar a aquest compte? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Voleu canviar a aquest compte? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-cs-rCZ/strings.xml b/app/src/main/res/values-cs-rCZ/strings.xml index 938ad0e3c..4960aa13f 100644 --- a/app/src/main/res/values-cs-rCZ/strings.xml +++ b/app/src/main/res/values-cs-rCZ/strings.xml @@ -207,7 +207,7 @@ Vypnuto Zapnuto Stav - Nejjednodušší způsob, jak přidat nové přihlašovací údaje do Vašeho trezoru, je služba automatického vyplňování Bitwardenu. Další informace o použití této služby naleznete na obrazovce \"Nastavení\". + Nejjednodušší způsob, jak přidat nové přihlašovací údaje do Vašeho trezoru, je služba automatického vyplňování Bitwarden. Další informace o použití této služby naleznete na obrazovce \"Nastavení\". Automatické vyplňování Chcete automaticky vyplnit nebo zobrazit tyto přihlašovací údaje? Opravdu chcete tyto přihlašovací údaje automaticky vyplnit? Nejsou úplně shodné s \"%1$s\". @@ -1040,7 +1040,7 @@ Chcete se přepnout na tento účet? Export Vašich uložených přihlášení Po dokončení importu tento soubor smažete. Na Vašem počítači otevřete novou kartu prohlížeče a přejděte na vault.bitwarden.com - přejděte na vault.bitwarden.com + přejděte na %1$s Přihlaste se do webové aplikace Bitwarden. Krok 2 ze 3 Přihlásit se do Bitwardenu @@ -1054,7 +1054,7 @@ Chcete se přepnout na tento účet? a poté klepněte na tlačítko Hotovo Z důvodu bezpečnosti nezapomeňte smazat soubor s uloženým heslem. smazat soubor s uloženým heslem. - Potřebujete pomoc? Podívejte se na nápovědu pro import. + Potřebujete pomoc? Podívejte se na nápovědu pro import. nápověda pro import Uložte exportovaný soubor někde na Vašem počítači, kde jej můžete snadno najít. Uložit exportovaný soubor @@ -1072,4 +1072,12 @@ Chcete se přepnout na tento účet? Nástroje Bitwarden Rozumím Nebyla importována žádná přihlášení + Přihlášení byla importována + Nezapomeňte smazat soubor importovaných hesel z Vašeho počítače + SSH klíč + Veřejný klíč + Soukromý klíč + SSH klíče + Kopírovat veřejný klíč + Kopírovat otisk prstu diff --git a/app/src/main/res/values-cy-rGB/strings.xml b/app/src/main/res/values-cy-rGB/strings.xml index 1222a610b..ceb653c72 100644 --- a/app/src/main/res/values-cy-rGB/strings.xml +++ b/app/src/main/res/values-cy-rGB/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-da-rDK/strings.xml b/app/src/main/res/values-da-rDK/strings.xml index 53b2ecdc3..9b6b9c22f 100644 --- a/app/src/main/res/values-da-rDK/strings.xml +++ b/app/src/main/res/values-da-rDK/strings.xml @@ -1055,7 +1055,7 @@ Skift til denne konto? dernæst Færdig Sørg af sikkerhedshensyn for at slette den gemte adgangskodefil. slet den gemte adgangskodefil. - Behov for hjælp? Tjek import-hjælp ud. + Behov for hjælp? Tjek import-hjælp ud. import-hjælp Gem den eksporterede fil et sted på computeren, hvor man nemt kan finde den. Gem den eksporterede fil @@ -1073,4 +1073,12 @@ Skift til denne konto? Bitwarden-værktøjer Forstået Ingen logins blev importeret + Logins importeret + Husk at slette den importerede adgangskodefil fra computeren + SSH-nøgle + Offentlig nøgle + Privat nøgle + SSH-nøgler + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 1563b55cf..b7f75c99f 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -1054,7 +1054,7 @@ Möchtest du zu diesem Konto wechseln? then Done Zu deiner eigenen Sicherheit solltest du deine gespeicherte Passwortdatei löschen. lösche deine gespeicherte Passwortdatei. - Brauchst du Hilfe? Schau dir die Importhilfe an. + Need help? Check out import help. Importhilfe Speichere die exportierte Datei irgendwo auf deinem Computer, so dass du sie leicht wiederfinden kannst. Speichere die exportierte Datei @@ -1072,4 +1072,12 @@ Möchtest du zu diesem Konto wechseln? Bitwarden-Werkzeuge Verstanden Es wurden keine Zugangsdaten importiert + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-el-rGR/strings.xml b/app/src/main/res/values-el-rGR/strings.xml index d51826e03..af0f0bc10 100644 --- a/app/src/main/res/values-el-rGR/strings.xml +++ b/app/src/main/res/values-el-rGR/strings.xml @@ -1040,8 +1040,8 @@ Βήμα 1 από 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Βήμα 2 από 3 Σύνδεση στο Bitwarden @@ -1055,7 +1055,7 @@ then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + Κλειδί SSH + Δημόσιο κλειδί + Ιδιωτικό κλειδί + Κλειδιά SSH + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 5c5b8363a..52d451a93 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-en-rIN/strings.xml b/app/src/main/res/values-en-rIN/strings.xml index ad362346a..816fa9b2c 100644 --- a/app/src/main/res/values-en-rIN/strings.xml +++ b/app/src/main/res/values-en-rIN/strings.xml @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 84ca4ba60..e1ef7d37f 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1002,7 +1002,7 @@ seleccione Agregar TOTP para almacenar la clave de forma segura Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1041,8 +1041,8 @@ seleccione Agregar TOTP para almacenar la clave de forma segura Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1056,7 +1056,7 @@ seleccione Agregar TOTP para almacenar la clave de forma segura then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1074,4 +1074,12 @@ seleccione Agregar TOTP para almacenar la clave de forma segura Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-et-rEE/strings.xml b/app/src/main/res/values-et-rEE/strings.xml index 8b9f65388..010d5ecb3 100644 --- a/app/src/main/res/values-et-rEE/strings.xml +++ b/app/src/main/res/values-et-rEE/strings.xml @@ -1001,7 +1001,7 @@ Soovid selle konto peale lülituda? Palun alusta uuesti registreerimist või proovi sisse logida. Sul võib olla konto juba olemas. Alusta registreerimist uuesti Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Soovid selle konto peale lülituda? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Soovid selle konto peale lülituda? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Soovid selle konto peale lülituda? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-eu-rES/strings.xml b/app/src/main/res/values-eu-rES/strings.xml index b4ec404bc..ef0bb8504 100644 --- a/app/src/main/res/values-eu-rES/strings.xml +++ b/app/src/main/res/values-eu-rES/strings.xml @@ -999,7 +999,7 @@ Kontu honetara aldatu nahi duzu? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1038,8 +1038,8 @@ Kontu honetara aldatu nahi duzu? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1053,7 +1053,7 @@ Kontu honetara aldatu nahi duzu? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1071,4 +1071,12 @@ Kontu honetara aldatu nahi duzu? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml index 8b8dab534..186d51d67 100644 --- a/app/src/main/res/values-fa-rIR/strings.xml +++ b/app/src/main/res/values-fa-rIR/strings.xml @@ -1001,7 +1001,7 @@ Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index ce0f10ead..b173c7897 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -321,7 +321,7 @@ Koodi skannataan automaattisesti. Titteli Postinumero Osoite - Erääntymisaika + Voimassaolo päättyy Näytä sivustokuvakkeet Näytä tunnistettava kuva jokaiselle kirjautumistiedolle. Kuvakepalvelimen URL @@ -573,7 +573,7 @@ Koodi skannataan automaattisesti. Send poistuu pysyvästi määritettynä ajankohtana. Odottaa poistoa Erääntymispäivä - Erääntymisaika + Voimassaolo päättyy Send erääntyy määritettynä ajankohtana. Erääntynyt Käyttökertojen enimmäismäärä @@ -1040,7 +1040,7 @@ Haluatko vaihtaa tähän tiliin? Vaihe 1/3 Vie tallennetut kirjautumistietosi Poistat tämän tiedoston, kun tuonti on valmis. - On your computer, open a new browser tab and go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s mene osoitteeseen vault.bitwarden.com Kirjaudu Bitwarden-verkkosovellukseen. Vaihe 2/3 @@ -1055,22 +1055,30 @@ Haluatko vaihtaa tähän tiliin? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. - Save the exported file + Tallenna viety tiedosto This is not a recognized Bitwarden server. You may need to check with your provider or update your server. - Syncing logins... + Synkronoidaan kirjautumistietoja... SSH Key Cipher Item Types - Download the browser extension + Lataa selaimen laajennus Go to bitwarden.com/download to integrate Bitwarden into your favorite browser for a seamless experience. - Use the web app + Käytä verkkosovellusta Log in at bitwarden.com to easily manage your account and update settings. - Autofill passwords + Automaattitäytä salasanoja Set up autofill on all your devices to login with a single tap anywhere. - Import Successful! + Tuonti onnistui! Manage your logins from anywhere with Bitwarden tools for web and desktop. - Bitwarden Tools - Got it - No logins were imported + Bitwarden-työkalut + Selvä + Kirjautumistietoja ei tuotu + Kirjautumistietoja tuotiin + Remember to delete your imported password file from your computer + SSH-avain + Julkinen avain + Yksityinen avain + SSH-avaimet + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-fil-rPH/strings.xml b/app/src/main/res/values-fil-rPH/strings.xml index b89531e10..f38c0d744 100644 --- a/app/src/main/res/values-fil-rPH/strings.xml +++ b/app/src/main/res/values-fil-rPH/strings.xml @@ -1001,7 +1001,7 @@ Gusto mo bang pumunta sa account na ito? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Gusto mo bang pumunta sa account na ito? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Gusto mo bang pumunta sa account na ito? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Gusto mo bang pumunta sa account na ito? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 2c1489787..e3348780e 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -1001,7 +1001,7 @@ Voulez-vous basculer vers ce compte ? Veuillez recommencer l\'inscription ou essayer de vous connecter. Vous avez peut-être déjà un compte. Recommencer l\'inscription Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing Un problème est survenu lors de la validation du jeton d\'enregistrement. Activer le remplissage automatique Utilisez le remplissage automatique pour vous connecter à vos comptes en un seul clic. @@ -1040,8 +1040,8 @@ Voulez-vous basculer vers ce compte ? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Voulez-vous basculer vers ce compte ? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Voulez-vous basculer vers ce compte ? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-gl-rES/strings.xml b/app/src/main/res/values-gl-rES/strings.xml index 5de27233e..684b33a78 100644 --- a/app/src/main/res/values-gl-rES/strings.xml +++ b/app/src/main/res/values-gl-rES/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-hi-rIN/strings.xml b/app/src/main/res/values-hi-rIN/strings.xml index c206ca976..7199f3e93 100644 --- a/app/src/main/res/values-hi-rIN/strings.xml +++ b/app/src/main/res/values-hi-rIN/strings.xml @@ -1000,7 +1000,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1039,8 +1039,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1054,7 +1054,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1072,4 +1072,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-hr-rHR/strings.xml b/app/src/main/res/values-hr-rHR/strings.xml index 5e68727bd..07e05b423 100644 --- a/app/src/main/res/values-hr-rHR/strings.xml +++ b/app/src/main/res/values-hr-rHR/strings.xml @@ -1038,8 +1038,8 @@ Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1053,7 +1053,7 @@ then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1071,4 +1071,12 @@ Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index fe96cc9db..32fa6c68a 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -1054,7 +1054,7 @@ Szeretnénk átváltani erre a fiókra? majd Kész lehetőség A biztonság érdekében töröljük a mentett jelszó fájlt. a mentett jelszó fájl törlése. - Segítségre van szükség? Nézzük meg az importálás súgót. + Segítségre van szükség? Nézzük meg az importálás súgót. importálás súgó Mentsük el az exportált fájlt olyan helyre a számítógépen, ahol könnyen megtalálhatjuk. Exportált fájl mentése @@ -1072,4 +1072,12 @@ Szeretnénk átváltani erre a fiókra? Bitwarden eszközök Rendben Nem lett bejelentkezés importáva. + A bejelentkezések importálásra kerültek. + Ne felejtsük el törölni az importált jelszófájlt a számítógépről. + SSH kulcs + Nyilvános kulcs + Személyes kulcs + SSH kulcsok + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-in-rID/strings.xml b/app/src/main/res/values-in-rID/strings.xml index 847917e98..c61fc8b42 100644 --- a/app/src/main/res/values-in-rID/strings.xml +++ b/app/src/main/res/values-in-rID/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index 400eb15d3..636db10e5 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -1039,8 +1039,8 @@ Vuoi passare a questo account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1054,7 +1054,7 @@ Vuoi passare a questo account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1072,4 +1072,12 @@ Vuoi passare a questo account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-iw-rIL/strings.xml b/app/src/main/res/values-iw-rIL/strings.xml index debc4c8fd..03a310b3c 100644 --- a/app/src/main/res/values-iw-rIL/strings.xml +++ b/app/src/main/res/values-iw-rIL/strings.xml @@ -1004,7 +1004,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1043,8 +1043,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1058,7 +1058,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1076,4 +1076,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index c58f0bc3c..7f1733249 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -1040,8 +1040,8 @@ Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-ka-rGE/strings.xml b/app/src/main/res/values-ka-rGE/strings.xml index 8df7b6fa1..1263eba5d 100644 --- a/app/src/main/res/values-ka-rGE/strings.xml +++ b/app/src/main/res/values-ka-rGE/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-kn-rIN/strings.xml b/app/src/main/res/values-kn-rIN/strings.xml index 9379b27ad..d95418c9c 100644 --- a/app/src/main/res/values-kn-rIN/strings.xml +++ b/app/src/main/res/values-kn-rIN/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 6ac153be9..716b1e3a8 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -1001,7 +1001,7 @@ Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-lt-rLT/strings.xml b/app/src/main/res/values-lt-rLT/strings.xml index a1b3ddd0f..247729907 100644 --- a/app/src/main/res/values-lt-rLT/strings.xml +++ b/app/src/main/res/values-lt-rLT/strings.xml @@ -1001,7 +1001,7 @@ Ar norite pereiti prie šios paskyros? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Ar norite pereiti prie šios paskyros? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Ar norite pereiti prie šios paskyros? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Ar norite pereiti prie šios paskyros? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-lv-rLV/strings.xml b/app/src/main/res/values-lv-rLV/strings.xml index c1dbeec30..929beda00 100644 --- a/app/src/main/res/values-lv-rLV/strings.xml +++ b/app/src/main/res/values-lv-rLV/strings.xml @@ -1055,7 +1055,7 @@ Vai pārslēgties uz šo kontu? tad \"Darīts\" Drošības dēļ jānodrošina, ka paroļu datne tiek izdzēsta. jāizdzēš sava salgabātā paroļu datne. - Nepieciešama palīdzība? Ir vērts ieskatīties ievietošanas palīdzībā. + Nepieciešama palīdzība? Ir vērts ieskatīties ievietošanas palīdzībā. ievietošanas palīdzība Izgūtā datne jāsaglabā kaut kur viegli atrodamā vietā datorā. Izgūtā datne jāsaglabā @@ -1073,4 +1073,12 @@ Vai pārslēgties uz šo kontu? Bitwarden rīki Sapratu Netika ievietots neviens pieteikšanās vienums + Pieteikšanās vienumi ievietoti + Jāatceras datorā izdzēst savu ievietoto paroļu datni + SSH atslēga + Publiskā atslēga + Privātā atslēga + SSH atslēgas + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-ml-rIN/strings.xml b/app/src/main/res/values-ml-rIN/strings.xml index 2d070aa59..e60279a5d 100644 --- a/app/src/main/res/values-ml-rIN/strings.xml +++ b/app/src/main/res/values-ml-rIN/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-mr-rIN/strings.xml b/app/src/main/res/values-mr-rIN/strings.xml index 0a24ad5b4..b2a8cba26 100644 --- a/app/src/main/res/values-mr-rIN/strings.xml +++ b/app/src/main/res/values-mr-rIN/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-my-rMM/strings.xml b/app/src/main/res/values-my-rMM/strings.xml index 8df7b6fa1..1263eba5d 100644 --- a/app/src/main/res/values-my-rMM/strings.xml +++ b/app/src/main/res/values-my-rMM/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index cb30f13ff..b0d66db41 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -1001,7 +1001,7 @@ Vil du bytte til denne kontoen? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Vil du bytte til denne kontoen? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Vil du bytte til denne kontoen? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Vil du bytte til denne kontoen? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-ne-rNP/strings.xml b/app/src/main/res/values-ne-rNP/strings.xml index 8df7b6fa1..1263eba5d 100644 --- a/app/src/main/res/values-ne-rNP/strings.xml +++ b/app/src/main/res/values-ne-rNP/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-nl-rNL/strings.xml b/app/src/main/res/values-nl-rNL/strings.xml index 4df2132e2..4e0bd115b 100644 --- a/app/src/main/res/values-nl-rNL/strings.xml +++ b/app/src/main/res/values-nl-rNL/strings.xml @@ -1055,7 +1055,7 @@ Wilt u naar dit account wisselen? dan Klaar Voor je eigen veiligheid, verwijder je opgeslagen wachtwoordbestand. verwijder je opgeslagen wachtwoordbestand. - Hulp nodig? Bekijk importhulp. + Hulp nodig? Bekijk importhulp. importhulp Sla het geëxporteerde bestand ergens op je computer waar je deze gemakkelijk kunt vinden. Exportbestand opslaan @@ -1073,4 +1073,12 @@ Wilt u naar dit account wisselen? Bitwarden-hulpmiddelen Ik snap het Er zijn geen logins geïmporteerd + Inloggegevens geïmporteerd + Vergeet niet het geïmporteerde wachtwoordbestand van je computer te verwijderen + SSH-sleutel + Publieke sleutel + Privésleutel + SSH-sleutels + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-nn-rNO/strings.xml b/app/src/main/res/values-nn-rNO/strings.xml index 05d3c947b..0312c6bca 100644 --- a/app/src/main/res/values-nn-rNO/strings.xml +++ b/app/src/main/res/values-nn-rNO/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing Det oppstod eit problem under validering av registreringsnøkkelen. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index e8bc4d7dd..ba1c28ead 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 493b56ab0..c109c5f8d 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -1001,7 +1001,7 @@ Czy chcesz przełączyć się na to konto? Zacznij rejestrację od początku lub spróbuj się zalogować. Możesz mieć już konto. Zacznij rejestrację od początku Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing Wystąpił błąd podczas sprawdzania poprawności tokenu rejestracyjnego. Włącz autouzupełnienie Użyj autouzupełniania, aby zalogować się na swoje konta jednym dotknięciem. @@ -1040,8 +1040,8 @@ Czy chcesz przełączyć się na to konto? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Czy chcesz przełączyć się na to konto? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Czy chcesz przełączyć się na to konto? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 521bd17bb..e703f5e59 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1029,7 +1029,7 @@ Você deseja mudar para esta conta? Importar Logins A partir do seu computador, siga estas instruções para exportar senhas salvas do seu navegador ou de outro gerenciador de senhas. Em seguida, importe-as com segurança para o Bitwarden. Dê ao seu cofre uma entrada de cabeça - Unlock with biometrics requires strong biometric authentication and may not be compatible with all biometric options on this device. + O desbloqueio com dados biométricos requer uma autenticação biométrica forte e pode não ser compatível com todas as opções biométricas deste dispositivo. Unlock with biometrics requires strong biometric authentication and is not compatible with the biometrics options available on this device. On your computer, log in to your current browser or password manager. log in to your current browser or password manager. @@ -1055,7 +1055,7 @@ Você deseja mudar para esta conta? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Você deseja mudar para esta conta? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index eee168eb9..e331bc773 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1055,7 +1055,7 @@ Anule a subscrição em qualquer altura. em seguida, Concluído Para sua segurança, certifique-se de que elimina o ficheiro de palavras-passe guardadas. elimine o seu ficheiro de palavras-passe guardadas. - Precisa de ajuda? Consulte a ajuda com a importação. + Precisa de ajuda? Consulte a ajuda com a importação. ajuda com a importação Guarde o ficheiro exportado num local do seu computador que possa encontrar facilmente. Guarde o ficheiro exportado @@ -1073,4 +1073,12 @@ Anule a subscrição em qualquer altura. Ferramentas do Bitwarden Percebido Não foram importadas credenciais + Credenciais importadas + Lembre-se de eliminar o ficheiro da palavra-passe importada do seu computador + Chave SSH + Chave pública + Chave privada + Chaves SSH + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-ro-rRO/strings.xml b/app/src/main/res/values-ro-rRO/strings.xml index bde2629ba..9137f995b 100644 --- a/app/src/main/res/values-ro-rRO/strings.xml +++ b/app/src/main/res/values-ro-rRO/strings.xml @@ -1001,7 +1001,7 @@ Doriți să comutați la acest cont? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Activează completarea automată Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Doriți să comutați la acest cont? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Doriți să comutați la acest cont? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Doriți să comutați la acest cont? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 639039fd1..0335848f0 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -1057,22 +1057,30 @@ затем Готово В целях безопасности не забудьте удалить сохраненный файл с паролями. удалите файл с сохраненными паролями. - Нужна помощь? Ознакомьтесь с помощью по импорту. + Нужна помощь? Ознакомьтесь с помощью по импорту. помощь по импорту Сохраните экспортированный файл на компьютере, где его можно будет легко найти. Сохранить экспортированный файл Этот сервер Bitwarden не распознан. Возможно вам следует связаться с провайдером или изменить свой сервер. Синхронизация логинов... - SSH Key Cipher Item Types - Download the browser extension - Go to bitwarden.com/download to integrate Bitwarden into your favorite browser for a seamless experience. - Use the web app - Log in at bitwarden.com to easily manage your account and update settings. - Autofill passwords - Set up autofill on all your devices to login with a single tap anywhere. - Import Successful! - Manage your logins from anywhere with Bitwarden tools for web and desktop. - Bitwarden Tools - Got it - No logins were imported + Типы элементов ключей шифрования SSH + Скачать расширение браузера + Перейдите на bitwarden.com/download для интеграции Bitwarden в ваш любимый браузер для удобной работы. + Использовать веб-приложение + Войдите на bitwarden.com для простого управления вашим аккаунтом и обновления настроек. + Автозаполнение паролей + Настройте автозаполнение на всех своих устройствах, чтобы авторизоваться одним кликом везде. + Импорт выполнен успешно! + Управляйте своими логинами из любого места с помощью инструментов Bitwarden в интернете и с компьютера. + Инструменты Bitwarden + Понятно + Ни один логин не был импортирован + Логины импортированы + Не забудьте удалить файл импортированных паролей с компьютера + Ключ SSH + Публичный ключ + Приватный ключ + Ключи SSH + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-si-rLK/strings.xml b/app/src/main/res/values-si-rLK/strings.xml index c94b50eb0..98907381d 100644 --- a/app/src/main/res/values-si-rLK/strings.xml +++ b/app/src/main/res/values-si-rLK/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-sk-rSK/strings.xml b/app/src/main/res/values-sk-rSK/strings.xml index 453b44720..fc2d6e88a 100644 --- a/app/src/main/res/values-sk-rSK/strings.xml +++ b/app/src/main/res/values-sk-rSK/strings.xml @@ -1055,7 +1055,7 @@ Chcete prepnúť na tento účet? potom Hotovo V záujme vašej bezpečnosti nezabudnite odstrániť uložený súbor s heslami. odstrániť súbor s uloženými heslami. - Potrebujete pomoc? Pozrite si pomoc pre importovanie. + Potrebujete pomoc? Pozrite si pomoc pre importovanie. pomoc pre importovanie Uložte exportovaný súbor na miesto v počítači, ktoré ľahko nájdete. Uložte exportovaný súbor @@ -1073,4 +1073,12 @@ Chcete prepnúť na tento účet? Nástroje Bitwardenu Chápem Neboli importované žiadne prihlasovacie údaje + Prihlasovacie údaje boli importované + Nezabudnite z počítača odstrániť importovaný súbor s heslami + SSH kľúč + Verejný kľúč + Súkromný kľúč + SSH kľúče + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-sl-rSI/strings.xml b/app/src/main/res/values-sl-rSI/strings.xml index 41060c01b..2b43f3791 100644 --- a/app/src/main/res/values-sl-rSI/strings.xml +++ b/app/src/main/res/values-sl-rSI/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-sr-rSP/strings.xml b/app/src/main/res/values-sr-rSP/strings.xml index fb7f9cc92..c7340a14b 100644 --- a/app/src/main/res/values-sr-rSP/strings.xml +++ b/app/src/main/res/values-sr-rSP/strings.xml @@ -1002,7 +1002,7 @@ Поново покрените регистрацију или покушајте да се пријавите. Можда већ имате налог. Поново покрените регистрацију Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing Дошло је до проблема са валидацијом регистрационог токена. Омогућите ауто-пуњење Користите ауто-пуњење да бисте се пријавили на своје налоге једним додиром. @@ -1056,7 +1056,7 @@ и после Заврши Ради ваше безбедности, обавезно избришите своју сачувану датотеку лозинке. избришите своју сачувану датотеку лозинке. - Треба вам помоћ? Проверите помоћ за увоз. + Need help? Check out import help. помоћ за увоз Сачувајте извезену датотеку негде на рачунару где можете лако да пронађете. Сачувајте извезену датотеку @@ -1074,4 +1074,12 @@ Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index b1d6ae0bd..78afa60f5 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -196,7 +196,7 @@ Objekt för %1$s Det finns inga objekt i ditt valv för %1$s. There are no items in your vault that match “%1$s” - Search for a login or add a new login + Sök efter eller lägg till en ny inloggning När du väljer ett inmatningsfält och ser en ruta för automatisk ifyllnad från Bitwarden, kan du trycka på den för att starta tjänsten för automatisk ifyllnad. Tryck på den här aviseringen för att automatiskt fylla i en inloggning från ditt valv. Öppna tillgänglighetsinställningar @@ -1002,7 +1002,7 @@ Vill du byta till detta konto? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Slå på Autofyll Använd autofyll för att logga in på dina konton med ett tryck. @@ -1015,12 +1015,12 @@ Vill du byta till detta konto? Huvudlösenordsledtråd Viktigt: Ditt huvudlösenord kan inte återställas om du glömmer det! Minst 12 tecken. Kom igång - Save and protect your data + Spara och skydda din data The vault protects more than just passwords. Store secure logins, IDs, cards and notes securely here. - New login + Ny inloggning Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure. Send sensitive information, safely - Import saved logins + Importera sparade inloggningar Use a computer to import logins from an existing password manager You can return to complete this step anytime in Vault Settings. Import logins later @@ -1038,16 +1038,16 @@ Vill du byta till detta konto? Export your passwords. Select Import data in the web app, then Done to finish syncing. Select Import data - Step 1 of 3 + Steg 1 av 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + gå till vault.bitwarden.com Log in to the Bitwarden web app. - Step 2 of 3 - Log in to Bitwarden - Step 3 of 3 - Import logins to Bitwarden + Steg 2 av 3 + Logga in på Bitwarden + Steg 3 av 3 + Importera inloggningar till Bitwarden In the Bitwarden navigation, find the Tools option and select Import data. find the Tools select Import data. @@ -1056,7 +1056,7 @@ Vill du byta till detta konto? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1067,11 +1067,19 @@ Vill du byta till detta konto? Go to bitwarden.com/download to integrate Bitwarden into your favorite browser for a seamless experience. Use the web app Log in at bitwarden.com to easily manage your account and update settings. - Autofill passwords + Autofyll lösenord Set up autofill on all your devices to login with a single tap anywhere. Import Successful! Manage your logins from anywhere with Bitwarden tools for web and desktop. Bitwarden Tools Got it - No logins were imported + Inga inloggningar importerades + Logins imported + Remember to delete your imported password file from your computer + SSH-nyckel + Public key + Privat nyckel + SSH-nycklar + Copy public key + Kopiera fingeravtryck diff --git a/app/src/main/res/values-ta-rIN/strings.xml b/app/src/main/res/values-ta-rIN/strings.xml index 35414cf18..c52d5f0d9 100644 --- a/app/src/main/res/values-ta-rIN/strings.xml +++ b/app/src/main/res/values-ta-rIN/strings.xml @@ -41,7 +41,7 @@ செல்லாத கடவெண். மீண்டும் முயலவும். ஏவு உள்நுழை - Log in + உள்நுழை உள்நுழைவு வெளியேறு நிச்சயமாக வெளியேற விரும்புகிறீரா? @@ -50,7 +50,7 @@ கணக்கு ஏற்கனவே சேர்க்கப்பட்டது அதற்கு இப்போது நிலைமாற விரும்புகிறீரா? பிரதான கடவுச்சொல் - Master password (required) + பிரதான கடவுச்சொல் (தேவை) மேலும் என் பெட்டகம் அங்கீகரிப்பாளர் @@ -84,12 +84,12 @@ ஆம் கணக்கு உங்கள் புது கணக்கு உருவாக்கப்பட்டது, நீங்கள் இப்போது உள்நுழையலாம். - Your new account has been created! + உமது புது கணக்கு உருவாக்கப்பட்டது! ஓர் உருப்படியைச் சேர் செயலி நீட்டிப்பு செயலிகள் மற்றும் இணையம் முழுதுமுள்ள உமது உள்நுழைவுகளைத் தன்னிரப்ப Bitwarden அணுகல்தன்மை சேவையைப் பயன்படுத்து. தன்னிரப்பி சேவை - Set Bitwarden as your passkey provider in device settings. + Bitwarden-ஐ உமது கடவுவிசை வழங்குநராகச் சாதன அமைவுகளில் அமை. தெளிவற்ற எழுத்துக்களைத் தவிர் Bitwarden செயலி நீட்டிப்பு உம் பெட்டகத்திற்கு புதிய உள்நுழைவுகளைச் சேர்ப்பதற்கான மிக எளிய வழி பிட்வார்டன் செயலி நீட்டிப்பிலிருந்தே. \"அமைப்புகள்\" திரைக்குச் சென்று பிட்வார்டன் செயலி நீட்டிப்பைப் பயன்படுத்துவது பற்றி மேலுமறிக. @@ -151,7 +151,7 @@ உங்கள் பெட்டகத்தில் பிடித்தவை எவையுமில்லை. உம் பெட்டகத்தில் உருப்படிகள் ஏதுமில்லை. உம் பெட்டகத்தில் இவ்வலைத்தளம்/செயலிக்கான உருப்படிகள் இல்லை. ஒன்றைச் சேர்க்க தட்டுக. - No Username + பயனர்பெயர் இல்லை இவ்வுள்நுழைவில் பயனர்பெயர் அ கடவுச்சொல் கட்டமைக்கப்படவில்லை. சரி, புரிந்தது! விருப்ப இயல்புநிலைகள் முதன்மை Bitwarden செயலியின் கடவுச்சொல் உருவாக்கி கருவியிலிருந்து அமைக்கப்படுகிறது. @@ -167,7 +167,7 @@ ஒரு நல்ல விமர்சனம் மூலம் எங்களுக்கு உதவ தயவுசெய்து கருதுங்கள்! கடவுச்சொல்லை மீண்டுமுருவாக்கு பிரதான கடவுச்சொல்லை மீண்டும் தட்டு - Re-type master password (required) + பிரதான கடவுச்சொல்லை மீண்டும் உள்ளிடு (தேவை) பெட்டகத்தைத் தேடுக பாதுகாப்பு தேர்ந்தெடு @@ -195,8 +195,8 @@ மொழிபெயர்ப்புகள் %1$s க்கான உருப்படிகள் உம் பெட்டகத்தில் %1$s க்கான உருப்படிகள் ஏதுமில்லை. - There are no items in your vault that match “%1$s” - Search for a login or add a new login + உம் பெட்டகத்தில் “%1$s”-உடன் பொருந்தும் உருப்படிகள் இல்லை + உள்நுழைவைத் தேடு அல்லது புது உள்நுழைவைச் சேர் நீங்கள் ஓர் உள்ளீடு புலத்தை தேர்ந்து Bitwarden தன்னிரப்பி மேலடுக்கை பார்க்கும்போது, தன்னிரப்பி சேவையை தொடங்க நீங்கள் அதை தட்டலாம். உங்கள் பெட்டகத்திலிருந்து உருப்படியைத் தானாக-நிரப்ப இந்த அறிவிப்பைத் தட்டவும். அணுகல்தன்மை அமைவுகளைத் திற @@ -333,7 +333,7 @@ இக்கோப்புறையில் உருப்படிகள் ஏதுமில்லை. இக்குப்பையில் உருப்படிகள் ஏதுமில்லை. தன்னிரப்பி அணுகல்தன்மை சேவை - Assist with filling username and password fields in other apps and on the web. + பிற செயலிகளிலும் வலையிலும் பயனர்பெயர் மற்றும் கடவுச்சொல் புலங்களை நிரப்ப உதவு. Bitwarden தன்னிரப்பி சேவை உள்நுழைவு தகவலை உம் சாதனத்திலிருக்கும் பிற செயலிகளினுள்ளே நிரப்ப உதவ Android தன்னிரப்பி சட்டகத்தைப் பயன்படுத்துகிறது. பிற செயலிகளினுள்ளே உள்நுழைவு தகவலை நிரப்ப Bitwarden தன்னிரப்பி சேவை பயன்படுத்து. தன்னிரப்பல் அமைவுகளைத் திற @@ -458,7 +458,7 @@ குறிப்பை நகலெடு வெளியேறு Bitwarden இலிருந்து வெளியேற விரும்புகிறீர்களா? - Require master password on app restart? + செயலி மறுதுவக்கத்தில் பிரதான கடவுச்சொல் கேட்கவா? செயலி மறுதுவக்கத்தில் பூட்டவிழ்க்க உங்கள் பிரதான கடவுச்சொல்லை கோர வேண்டுமா? கருப்பு நார்ட் @@ -538,7 +538,7 @@ சேவை விதிமுறைகள் தனியுரிமை கொள்கை Bitwardenக்கு கவனம் தேவை - Bitwarden அமைப்புகளிலிருந்து \"தன்னிரப்பி சேவைகள்\"-இல் \"மேலே-வரைதல்\"-ஐ இயக்குக - Passkey management + கடவுவிசை நிர்வகிப்பு தன்னிரப்பி சேவைகள் உள்ளக தன்னிரப்பி பயன்படுத்து உம் தேர்ந்தெடுத்த IME (விசைப்பலகை) ஆதரித்தால் உள்வரி தன்னிரப்பி பயன்படுத்துக. உம் உள்ளமைவு ஆதரிக்கப்படவில்லையெனில் (அ இவ்விருப்பம் முடங்கியிருப்பின்), இயல்பிருப்பு தன்னிரப்பி மேலடுக்கு பயன்படுத்தப்படும். @@ -547,7 +547,7 @@ செயலிகளிலும் இணையம் முழுதுமுள்ள உமது உள்நுழைவுகளைத் தன்னிரப்ப Bitwarden அணுகல்தன்மை சேவை பயன்படுத்து. (மேலே-வரைதலும் இயங்க வேண்டும்) தன்னிரப்பு விரைவுச்செயல் ஓடு பயன்படுத்த மற்றும்/அல்லது மேலே-வரைதல்(இயங்கினால்) கொண்டு popup காட்ட Bitwarden அணுகல்தன்மை சேவை பயன்படுத்து. தன்னிரப்பு விரைவுச்செயல் ஓடு பயன்படுத்தவோ மேலே-வரைதல்(இயங்கினால்) கொண்டு தன்னிரப்பிச் சேவையை ஆதரவுமிகுதியாக்கவோ தேவைப்படுகிறது. - Required to use the Autofill Quick-Action Tile. + தன்னிரப்பு விரைவுச்செயல் ஓடு பயன்படுத்தத் தேவை. மேலே-வரைதல் பயன்படுத்து இயக்கினால், உள்நுழைவு புலங்கள் தேர்ந்தெடுக்கப்படும்போது ஒரு popup காண்பிக்க Bitwarden அணுகல்தன்மை சேவையை அனுமதிக்கிறது. இயக்கினால், உள்நுழைவு புலங்கள் தேர்ந்தெடுக்கப்படும்போது உமது உள்நுழைவுகளைத் தன்னிரப்ப உதவ Bitwarden அணுகல்தன்மை சேவை ஒரு popup காண்பிக்கும். @@ -556,7 +556,7 @@ ஒரு நிறுவன கொள்கை உம் உரிமை விருப்பங்களைப் பாதிக்கிறது. Send எல்லா Sends - Sends + Sendகள் இந்த Send ஐ விளக்க ஒரு நட்பார்ந்த பெயர். சொற்சரம் சொற்சரம் @@ -802,7 +802,7 @@ இடம் Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour. Current master password - Logged in! + உள்நுழைந்தீர்! எனது மற்றொரு சாதனத்துடன் ஒப்புதலளி நிர்வாகி ஒப்புதல் கோரு பிரதான கடவுச்சொலுடன் ஒப்புக்கொள் @@ -887,10 +887,10 @@ Your organization permissions were updated, requiring you to set a master password. Your organization requires you to set a master password. Set up an unlock option to change your vault timeout action. - Choose a login to save this passkey to - Save passkey as new login - Save passkey - Passkeys for %1$s + கடவுவிசையைச் சேமிக்க உள்நுழைவைத் தேர்ந்தெடு + கடவுவிசையைப் புதிய உள்நுழைவாகச் சேமி + கடவுவிசையைச் சேமி + %1$s-க்கானக் கடவுவிசைகள் Passwords for %1$s Overwrite passkey? This item already contains a passkey. Are you sure you want to overwrite the current passkey? @@ -1001,7 +1001,7 @@ Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-te-rIN/strings.xml b/app/src/main/res/values-te-rIN/strings.xml index 8df7b6fa1..1263eba5d 100644 --- a/app/src/main/res/values-te-rIN/strings.xml +++ b/app/src/main/res/values-te-rIN/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-th-rTH/strings.xml b/app/src/main/res/values-th-rTH/strings.xml index 5ab26038e..167b78c7c 100644 --- a/app/src/main/res/values-th-rTH/strings.xml +++ b/app/src/main/res/values-th-rTH/strings.xml @@ -1001,7 +1001,7 @@ Do you want to switch to this account? Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Do you want to switch to this account? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ Do you want to switch to this account? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Do you want to switch to this account? Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 981406d4e..4752f9a29 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -1000,7 +1000,7 @@ Bu hesaba geçmek ister misiniz? Lütfen kaydı yeniden başlatın veya giriş yapmayı deneyin. Mevcut bir hesabınız olabilir. Kaydı yeniden başlat Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Otomatik doldurmayı etkinleştir Hesaplarınıza tek dokunuşla giriş yapmak için otomatik doldurmayı kullanabilirsiniz. @@ -1039,8 +1039,8 @@ Bu hesaba geçmek ister misiniz? Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1054,7 +1054,7 @@ Bu hesaba geçmek ister misiniz? then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Yardıma mı ihtiyacınız var? İçe aktarma yardımına göz atın. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1072,4 +1072,12 @@ Bu hesaba geçmek ister misiniz? Bitwarden Araçları Anladım Hiç hesap aktarılmadı + Hesaplar içe aktarıldı + İçe aktardığınız parola dosyasını bilgisayarınızdan silmeyi unutmayın + SSH anahtarı + Ortak anahtar + Özel anahtar + SSH anahtarları + Ortak anahtarı kopyala + Parmak izini kopyala diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 3ff61b3c5..346af8668 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -1055,22 +1055,30 @@ потім натисніть Виконано З міркувань безпеки, обов\'язково видаліть збережений файл із паролями. видаліть збережений файл із паролями. - Потрібна допомога? Перегляньте довідку щодо імпорту. + Need help? Check out import help. довідка щодо імпорту Збережіть експортований файл у легкодоступному місці на цьому комп\'ютері. Збережіть експортований файл Це не розпізнаний сервер Bitwarden. Можливо, вам необхідно перевірити параметри свого провайдера або оновити свій сервер. Синхронізація записів... - SSH Key Cipher Item Types - Download the browser extension - Go to bitwarden.com/download to integrate Bitwarden into your favorite browser for a seamless experience. - Use the web app - Log in at bitwarden.com to easily manage your account and update settings. - Autofill passwords - Set up autofill on all your devices to login with a single tap anywhere. - Import Successful! - Manage your logins from anywhere with Bitwarden tools for web and desktop. - Bitwarden Tools - Got it - No logins were imported + Типи елементів ключів шифрування SSH + Завантажити розширення браузера + Відкрийте bitwarden.com/download, щоб інтегрувати Bitwarden у свій браузер для найкращої продуктивності. + Використовувати веб-застосунок + Увійдіть на bitwarden.com, щоб легко керувати обліковим записом і змінювати налаштування. + Автозаповнення паролів + Налаштуйте автозаповнення на всіх пристроях для входу одним дотиком. + Успішно імпортовано! + Керуйте своїми паролями звідусіль за допомогою вебінструментів та програм Bitwarden. + Інструменти Bitwarden + Зрозуміло + Жодного запису не імпортовано + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-vi-rVN/strings.xml b/app/src/main/res/values-vi-rVN/strings.xml index b66ad1bc6..a13f820d1 100644 --- a/app/src/main/res/values-vi-rVN/strings.xml +++ b/app/src/main/res/values-vi-rVN/strings.xml @@ -1055,22 +1055,30 @@ Bạn có muốn chuyển sang tài khoản này không? rồi Xong Để bảo mật, hãy xóa file mật khẩu đã lưu. xóa file mật khẩu đã lưu. - Cần trợ giúp? Xem trợ giúp nhập. + Cần trợ giúp? Xem trợ giúp nhập. trợ giúp nhập Lưu file đã xuất trên máy tính để tìm dễ hơn. Lưu file đã xuất Đây là một máy chủ Bitwarden chưa rõ. Bạn có thể cần kiểm tra với nhà cung cấp hoặc cập nhật máy chủ của mình. Đang đồng bộ... SSH Key Cipher Item Types - Download the browser extension - Go to bitwarden.com/download to integrate Bitwarden into your favorite browser for a seamless experience. - Use the web app - Log in at bitwarden.com to easily manage your account and update settings. - Autofill passwords - Set up autofill on all your devices to login with a single tap anywhere. - Import Successful! - Manage your logins from anywhere with Bitwarden tools for web and desktop. - Bitwarden Tools - Got it - No logins were imported + Tải tiện ích trình duyệt + Truy cập bitwarden.com/download để tích hợp Bitwarden vào trình duyệt yêu thích của bạn. + Dùng bản web + Đăng nhập bitwarden.com để quản lý tài khoản và cập nhật thiết lập dễ dàng. + Tự động điền mật khẩu + Thiết lập tính năng tự động điền trên mọi thiết bị của bạn để đăng nhập chỉ bằng một lần chạm ở bất kỳ đâu. + Nhập thành công! + Quản lý thông tin đăng nhập của bạn từ mọi nơi bằng công cụ Bitwarden dành cho web và máy tính để bàn. + Công cụ Bitwarden + Đã hiểu + Chưa nhập lượt đăng nhập nào + Đã nhập lượt đăng nhập + Nhớ xóa file mật khẩu đã nhập khỏi máy tính + Khóa SSH + Khóa công khai + Khóa riêng tư + Khóa SSH + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4e8d5b9d3..e0fab051b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1055,7 +1055,7 @@ 然后「完成」 为了您的安全,请务必删除已保存的密码文件。 删除已保存的密码文件。 - 需要帮助吗?查看导入帮助。 + Need help? Check out import help. 导入帮助 将导出的文件保存到您可以在计算机上轻松找到的地方。 保存导出的文件 @@ -1073,4 +1073,12 @@ Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a091e0b43..d485e0ed0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1001,7 +1001,7 @@ Please restart registration or try logging in. You may already have an account. Restart registration Authenticator Sync - Allow Bitwarden Authenticator Syncing + Allow authenticator syncing There was an issue validating the registration token. Turn on autofill Use autofill to log into your accounts with a single tap. @@ -1040,8 +1040,8 @@ Step 1 of 3 Export your saved logins You’ll delete this file after import is complete. - On your computer, open a new browser tab and go to vault.bitwarden.com - go to vault.bitwarden.com + On your computer, open a new browser tab and go to %1$s + go to %1$s Log in to the Bitwarden web app. Step 2 of 3 Log in to Bitwarden @@ -1055,7 +1055,7 @@ then Done For your security, be sure to delete your saved password file. delete your saved password file. - Need help? Checkout out import help. + Need help? Check out import help. import help Save the exported file somewhere on your computer you can find easily. Save the exported file @@ -1073,4 +1073,12 @@ Bitwarden Tools Got it No logins were imported + Logins imported + Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys + Copy public key + Copy fingerprint From 29384596d4ab1a4e6162291b2856ee03bf3d5c84 Mon Sep 17 00:00:00 2001 From: David Perez Date: Wed, 6 Nov 2024 11:40:54 -0600 Subject: [PATCH 7/9] PM-14410: App restart timeout action (#4237) --- .../di/ActivityAccessibilityModule.kt | 6 +- .../AccessibilityActivityManagerImpl.kt | 6 +- .../autofill/di/ActivityAutofillModule.kt | 6 +- .../manager/AutofillActivityManagerImpl.kt | 6 +- .../platform/manager/AppForegroundManager.kt | 15 --- .../manager/AppForegroundManagerImpl.kt | 36 ----- .../data/platform/manager/AppStateManager.kt | 24 ++++ .../platform/manager/AppStateManagerImpl.kt | 72 ++++++++++ .../manager/di/PlatformManagerModule.kt | 13 +- .../manager/model/AppCreationState.kt | 16 +++ .../restriction/RestrictionManagerImpl.kt | 6 +- .../vault/manager/VaultLockManagerImpl.kt | 29 ++++- .../vault/manager/di/VaultManagerModule.kt | 6 +- .../AccessibilityActivityManagerTest.kt | 6 +- .../manager/AutofillActivityManagerTest.kt | 6 +- .../manager/AppForegroundManagerTest.kt | 44 ------- .../platform/manager/AppStateManagerTest.kt | 82 ++++++++++++ .../restriction/RestrictionManagerTest.kt | 30 ++--- ...roundManager.kt => FakeAppStateManager.kt} | 20 ++- .../vault/manager/VaultLockManagerTest.kt | 123 ++++++++++++++---- docs/ARCHITECTURE.md | 2 +- 21 files changed, 380 insertions(+), 174 deletions(-) delete mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManager.kt delete mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManagerImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppStateManager.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppStateManagerImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/AppCreationState.kt delete mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManagerTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppStateManagerTest.kt rename app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/{FakeAppForegroundManager.kt => FakeAppStateManager.kt} (52%) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/di/ActivityAccessibilityModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/di/ActivityAccessibilityModule.kt index 33ddff1f5..636a6d15d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/di/ActivityAccessibilityModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/di/ActivityAccessibilityModule.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.LifecycleCoroutineScope import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityActivityManager import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityActivityManagerImpl import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.AppStateManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,13 +24,13 @@ object ActivityAccessibilityModule { fun providesAccessibilityActivityManager( @ApplicationContext context: Context, accessibilityEnabledManager: AccessibilityEnabledManager, - appForegroundManager: AppForegroundManager, + appStateManager: AppStateManager, lifecycleScope: LifecycleCoroutineScope, ): AccessibilityActivityManager = AccessibilityActivityManagerImpl( context = context, accessibilityEnabledManager = accessibilityEnabledManager, - appForegroundManager = appForegroundManager, + appStateManager = appStateManager, lifecycleScope = lifecycleScope, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerImpl.kt index 62670454b..0777298b6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerImpl.kt @@ -3,7 +3,7 @@ package com.x8bit.bitwarden.data.autofill.accessibility.manager import android.content.Context import androidx.lifecycle.LifecycleCoroutineScope import com.x8bit.bitwarden.data.autofill.accessibility.util.isAccessibilityServiceEnabled -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.AppStateManager import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -13,11 +13,11 @@ import kotlinx.coroutines.flow.onEach class AccessibilityActivityManagerImpl( private val context: Context, private val accessibilityEnabledManager: AccessibilityEnabledManager, - appForegroundManager: AppForegroundManager, + appStateManager: AppStateManager, lifecycleScope: LifecycleCoroutineScope, ) : AccessibilityActivityManager { init { - appForegroundManager + appStateManager .appForegroundStateFlow .onEach { accessibilityEnabledManager.isAccessibilityEnabled = diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/ActivityAutofillModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/ActivityAutofillModule.kt index e5d4cba3b..2a63df444 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/ActivityAutofillModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/ActivityAutofillModule.kt @@ -8,7 +8,7 @@ import androidx.lifecycle.lifecycleScope import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManagerImpl import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.AppStateManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -27,13 +27,13 @@ object ActivityAutofillModule { @Provides fun provideAutofillActivityManager( @ActivityScopedManager autofillManager: AutofillManager, - appForegroundManager: AppForegroundManager, + appStateManager: AppStateManager, autofillEnabledManager: AutofillEnabledManager, lifecycleScope: LifecycleCoroutineScope, ): AutofillActivityManager = AutofillActivityManagerImpl( autofillManager = autofillManager, - appForegroundManager = appForegroundManager, + appStateManager = appStateManager, autofillEnabledManager = autofillEnabledManager, lifecycleScope = lifecycleScope, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillActivityManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillActivityManagerImpl.kt index e01a7713d..154be4db9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillActivityManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillActivityManagerImpl.kt @@ -2,7 +2,7 @@ package com.x8bit.bitwarden.data.autofill.manager import android.view.autofill.AutofillManager import androidx.lifecycle.LifecycleCoroutineScope -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.AppStateManager import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.onEach class AutofillActivityManagerImpl( private val autofillManager: AutofillManager, private val autofillEnabledManager: AutofillEnabledManager, - appForegroundManager: AppForegroundManager, + appStateManager: AppStateManager, lifecycleScope: LifecycleCoroutineScope, ) : AutofillActivityManager { private val isAutofillEnabledAndSupported: Boolean @@ -21,7 +21,7 @@ class AutofillActivityManagerImpl( autofillManager.isAutofillSupported init { - appForegroundManager + appStateManager .appForegroundStateFlow .onEach { autofillEnabledManager.isAutofillEnabled = isAutofillEnabledAndSupported } .launchIn(lifecycleScope) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManager.kt deleted file mode 100644 index 268842c5f..000000000 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManager.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.x8bit.bitwarden.data.platform.manager - -import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState -import kotlinx.coroutines.flow.StateFlow - -/** - * A manager for tracking app foreground state changes. - */ -interface AppForegroundManager { - - /** - * Emits whenever there are changes to the app foreground state. - */ - val appForegroundStateFlow: StateFlow -} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManagerImpl.kt deleted file mode 100644 index 3f0a51075..000000000 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManagerImpl.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.x8bit.bitwarden.data.platform.manager - -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner -import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * Primary implementation of [AppForegroundManager]. - */ -class AppForegroundManagerImpl( - processLifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(), -) : AppForegroundManager { - private val mutableAppForegroundStateFlow = - MutableStateFlow(AppForegroundState.BACKGROUNDED) - - override val appForegroundStateFlow: StateFlow - get() = mutableAppForegroundStateFlow.asStateFlow() - - init { - processLifecycleOwner.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onStart(owner: LifecycleOwner) { - mutableAppForegroundStateFlow.value = AppForegroundState.FOREGROUNDED - } - - override fun onStop(owner: LifecycleOwner) { - mutableAppForegroundStateFlow.value = AppForegroundState.BACKGROUNDED - } - }, - ) - } -} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppStateManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppStateManager.kt new file mode 100644 index 000000000..7bf69d039 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppStateManager.kt @@ -0,0 +1,24 @@ +package com.x8bit.bitwarden.data.platform.manager + +import com.x8bit.bitwarden.data.autofill.accessibility.BitwardenAccessibilityService +import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState +import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState +import kotlinx.coroutines.flow.StateFlow + +/** + * A manager for tracking app foreground state changes. + */ +interface AppStateManager { + /** + * Emits whenever there are changes to the app creation state. + * + * This is required because the [BitwardenAccessibilityService] will keep the app process alive + * when the app would otherwise be destroyed. + */ + val appCreatedStateFlow: StateFlow + + /** + * Emits whenever there are changes to the app foreground state. + */ + val appForegroundStateFlow: StateFlow +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppStateManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppStateManagerImpl.kt new file mode 100644 index 000000000..4baa775c0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppStateManagerImpl.kt @@ -0,0 +1,72 @@ +package com.x8bit.bitwarden.data.platform.manager + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState +import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Primary implementation of [AppStateManager]. + */ +class AppStateManagerImpl( + application: Application, + processLifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(), +) : AppStateManager { + private val mutableAppCreationStateFlow = MutableStateFlow(AppCreationState.DESTROYED) + private val mutableAppForegroundStateFlow = MutableStateFlow(AppForegroundState.BACKGROUNDED) + + override val appCreatedStateFlow: StateFlow + get() = mutableAppCreationStateFlow.asStateFlow() + + override val appForegroundStateFlow: StateFlow + get() = mutableAppForegroundStateFlow.asStateFlow() + + init { + application.registerActivityLifecycleCallbacks(AppCreationCallback()) + processLifecycleOwner.lifecycle.addObserver(AppForegroundObserver()) + } + + private inner class AppForegroundObserver : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + mutableAppForegroundStateFlow.value = AppForegroundState.FOREGROUNDED + } + + override fun onStop(owner: LifecycleOwner) { + mutableAppForegroundStateFlow.value = AppForegroundState.BACKGROUNDED + } + } + + private inner class AppCreationCallback : Application.ActivityLifecycleCallbacks { + private var activityCount: Int = 0 + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + activityCount++ + // Always be in a created state if we have an activity + mutableAppCreationStateFlow.value = AppCreationState.CREATED + } + + override fun onActivityDestroyed(activity: Activity) { + activityCount-- + if (activityCount == 0 && !activity.isChangingConfigurations) { + mutableAppCreationStateFlow.value = AppCreationState.DESTROYED + } + } + + override fun onActivityStarted(activity: Activity) = Unit + + override fun onActivityResumed(activity: Activity) = Unit + + override fun onActivityPaused(activity: Activity) = Unit + + override fun onActivityStopped(activity: Activity) = Unit + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt index ce53e923d..b962ccb8f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt @@ -14,8 +14,8 @@ import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.Refres import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors import com.x8bit.bitwarden.data.platform.datasource.network.service.EventService import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManagerImpl +import com.x8bit.bitwarden.data.platform.manager.AppStateManager +import com.x8bit.bitwarden.data.platform.manager.AppStateManagerImpl import com.x8bit.bitwarden.data.platform.manager.AssetManager import com.x8bit.bitwarden.data.platform.manager.AssetManagerImpl import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager @@ -80,8 +80,9 @@ object PlatformManagerModule { @Provides @Singleton - fun provideAppForegroundManager(): AppForegroundManager = - AppForegroundManagerImpl() + fun provideAppStateManager( + application: Application, + ): AppStateManager = AppStateManagerImpl(application = application) @Provides @Singleton @@ -267,11 +268,11 @@ object PlatformManagerModule { @Singleton fun provideRestrictionManager( @ApplicationContext context: Context, - appForegroundManager: AppForegroundManager, + appStateManager: AppStateManager, dispatcherManager: DispatcherManager, environmentRepository: EnvironmentRepository, ): RestrictionManager = RestrictionManagerImpl( - appForegroundManager = appForegroundManager, + appStateManager = appStateManager, dispatcherManager = dispatcherManager, context = context, environmentRepository = environmentRepository, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/AppCreationState.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/AppCreationState.kt new file mode 100644 index 000000000..e5bb388c9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/AppCreationState.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.data.platform.manager.model + +/** + * Represents the creation state of the app. + */ +enum class AppCreationState { + /** + * Denotes that the app is currently created. + */ + CREATED, + + /** + * Denotes that the app is currently destroyed. + */ + DESTROYED, +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/restriction/RestrictionManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/restriction/RestrictionManagerImpl.kt index 4f75d8d75..cac5655c4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/restriction/RestrictionManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/restriction/RestrictionManagerImpl.kt @@ -6,7 +6,7 @@ import android.content.Intent import android.content.IntentFilter import android.content.RestrictionsManager import android.os.Bundle -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.AppStateManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository @@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.onEach * The default implementation of the [RestrictionManager]. */ class RestrictionManagerImpl( - appForegroundManager: AppForegroundManager, + appStateManager: AppStateManager, dispatcherManager: DispatcherManager, private val context: Context, private val environmentRepository: EnvironmentRepository, @@ -31,7 +31,7 @@ class RestrictionManagerImpl( private var isReceiverRegistered = false init { - appForegroundManager + appStateManager .appForegroundStateFlow .onEach { when (it) { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt index 41189cb87..1320c2ca3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt @@ -12,8 +12,9 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.AppStateManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout @@ -65,7 +66,7 @@ class VaultLockManagerImpl( private val authSdkSource: AuthSdkSource, private val vaultSdkSource: VaultSdkSource, private val settingsRepository: SettingsRepository, - private val appForegroundManager: AppForegroundManager, + private val appStateManager: AppStateManager, private val userLogoutManager: UserLogoutManager, private val trustedDeviceManager: TrustedDeviceManager, dispatcherManager: DispatcherManager, @@ -90,6 +91,7 @@ class VaultLockManagerImpl( get() = mutableVaultStateEventSharedFlow.asSharedFlow() init { + observeAppCreationChanges() observeAppForegroundChanges() observeUserSwitchingChanges() observeVaultTimeoutChanges() @@ -302,10 +304,31 @@ class VaultLockManagerImpl( } } + private fun observeAppCreationChanges() { + appStateManager + .appCreatedStateFlow + .onEach { appCreationState -> + when (appCreationState) { + AppCreationState.CREATED -> Unit + AppCreationState.DESTROYED -> handleOnDestroyed() + } + } + .launchIn(unconfinedScope) + } + + private fun handleOnDestroyed() { + activeUserId?.let { userId -> + checkForVaultTimeout( + userId = userId, + checkTimeoutReason = CheckTimeoutReason.APP_RESTARTED, + ) + } + } + private fun observeAppForegroundChanges() { var isFirstForeground = true - appForegroundManager + appStateManager .appForegroundStateFlow .onEach { appForegroundState -> when (appForegroundState) { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index 519ea6fc3..18ca9ebf2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -5,7 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.AppStateManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource @@ -72,7 +72,7 @@ object VaultManagerModule { authSdkSource: AuthSdkSource, vaultSdkSource: VaultSdkSource, settingsRepository: SettingsRepository, - appForegroundManager: AppForegroundManager, + appStateManager: AppStateManager, userLogoutManager: UserLogoutManager, dispatcherManager: DispatcherManager, trustedDeviceManager: TrustedDeviceManager, @@ -82,7 +82,7 @@ object VaultManagerModule { authSdkSource = authSdkSource, vaultSdkSource = vaultSdkSource, settingsRepository = settingsRepository, - appForegroundManager = appForegroundManager, + appStateManager = appStateManager, userLogoutManager = userLogoutManager, dispatcherManager = dispatcherManager, trustedDeviceManager = trustedDeviceManager, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerTest.kt index e328fc834..d8c5e63bd 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerTest.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.lifecycle.LifecycleCoroutineScope import app.cash.turbine.test import com.x8bit.bitwarden.data.autofill.accessibility.util.isAccessibilityServiceEnabled -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.AppStateManager import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState import io.mockk.every import io.mockk.mockk @@ -26,7 +26,7 @@ class AccessibilityActivityManagerTest { private val accessibilityEnabledManager: AccessibilityEnabledManager = AccessibilityEnabledManagerImpl() private val mutableAppForegroundStateFlow = MutableStateFlow(AppForegroundState.BACKGROUNDED) - private val appForegroundManager: AppForegroundManager = mockk { + private val appStateManager: AppStateManager = mockk { every { appForegroundStateFlow } returns mutableAppForegroundStateFlow } private val lifecycleScope = mockk { @@ -43,7 +43,7 @@ class AccessibilityActivityManagerTest { autofillActivityManager = AccessibilityActivityManagerImpl( context = context, accessibilityEnabledManager = accessibilityEnabledManager, - appForegroundManager = appForegroundManager, + appStateManager = appStateManager, lifecycleScope = lifecycleScope, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/manager/AutofillActivityManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/manager/AutofillActivityManagerTest.kt index 525f4d4ab..01ee3a0ce 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/manager/AutofillActivityManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/manager/AutofillActivityManagerTest.kt @@ -3,7 +3,7 @@ package com.x8bit.bitwarden.data.autofill.manager import android.view.autofill.AutofillManager import androidx.lifecycle.LifecycleCoroutineScope import app.cash.turbine.test -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.AppStateManager import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState import io.mockk.every import io.mockk.just @@ -28,7 +28,7 @@ class AutofillActivityManagerTest { private val autofillEnabledManager: AutofillEnabledManager = AutofillEnabledManagerImpl() private val mutableAppForegroundStateFlow = MutableStateFlow(AppForegroundState.BACKGROUNDED) - private val appForegroundManager: AppForegroundManager = mockk { + private val appStateManager: AppStateManager = mockk { every { appForegroundStateFlow } returns mutableAppForegroundStateFlow } private val lifecycleScope = mockk { @@ -39,7 +39,7 @@ class AutofillActivityManagerTest { @Suppress("unused") private val autofillActivityManager: AutofillActivityManager = AutofillActivityManagerImpl( autofillManager = autofillManager, - appForegroundManager = appForegroundManager, + appStateManager = appStateManager, autofillEnabledManager = autofillEnabledManager, lifecycleScope = lifecycleScope, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManagerTest.kt deleted file mode 100644 index fbf8c1d0d..000000000 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManagerTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.x8bit.bitwarden.data.platform.manager - -import app.cash.turbine.test -import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState -import com.x8bit.bitwarden.data.util.FakeLifecycleOwner -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class AppForegroundManagerTest { - - private val fakeLifecycleOwner = FakeLifecycleOwner() - - private val appForegroundManager = AppForegroundManagerImpl( - processLifecycleOwner = fakeLifecycleOwner, - ) - - @Suppress("MaxLineLength") - @Test - fun `appForegroundStateFlow should emit whenever the underlying ProcessLifecycleOwner receives start and stop events`() = - runTest { - appForegroundManager.appForegroundStateFlow.test { - // Initial state is BACKGROUNDED - assertEquals( - AppForegroundState.BACKGROUNDED, - awaitItem(), - ) - - fakeLifecycleOwner.lifecycle.dispatchOnStart() - - assertEquals( - AppForegroundState.FOREGROUNDED, - awaitItem(), - ) - - fakeLifecycleOwner.lifecycle.dispatchOnStop() - - assertEquals( - AppForegroundState.BACKGROUNDED, - awaitItem(), - ) - } - } -} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppStateManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppStateManagerTest.kt new file mode 100644 index 000000000..0021001d2 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppStateManagerTest.kt @@ -0,0 +1,82 @@ +package com.x8bit.bitwarden.data.platform.manager + +import android.app.Activity +import android.app.Application +import app.cash.turbine.test +import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState +import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState +import com.x8bit.bitwarden.data.util.FakeLifecycleOwner +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AppStateManagerTest { + + private val activityLifecycleCallbacks = slot() + private val application = mockk { + every { registerActivityLifecycleCallbacks(capture(activityLifecycleCallbacks)) } just runs + } + private val fakeLifecycleOwner = FakeLifecycleOwner() + + private val appStateManager = AppStateManagerImpl( + application = application, + processLifecycleOwner = fakeLifecycleOwner, + ) + + @Suppress("MaxLineLength") + @Test + fun `appForegroundStateFlow should emit whenever the underlying ProcessLifecycleOwner receives start and stop events`() = + runTest { + appStateManager.appForegroundStateFlow.test { + // Initial state is BACKGROUNDED + assertEquals( + AppForegroundState.BACKGROUNDED, + awaitItem(), + ) + + fakeLifecycleOwner.lifecycle.dispatchOnStart() + + assertEquals( + AppForegroundState.FOREGROUNDED, + awaitItem(), + ) + + fakeLifecycleOwner.lifecycle.dispatchOnStop() + + assertEquals( + AppForegroundState.BACKGROUNDED, + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `appCreatedStateFlow should emit whenever the underlying activities are all destroyed or a creation event occurs`() = + runTest { + val activity = mockk { + every { isChangingConfigurations } returns false + } + appStateManager.appCreatedStateFlow.test { + // Initial state is DESTROYED + assertEquals(AppCreationState.DESTROYED, awaitItem()) + + activityLifecycleCallbacks.captured.onActivityCreated(activity, null) + assertEquals(AppCreationState.CREATED, awaitItem()) + + activityLifecycleCallbacks.captured.onActivityCreated(activity, null) + expectNoEvents() + + activityLifecycleCallbacks.captured.onActivityDestroyed(activity) + expectNoEvents() + + activityLifecycleCallbacks.captured.onActivityDestroyed(activity) + assertEquals(AppCreationState.DESTROYED, awaitItem()) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/restriction/RestrictionManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/restriction/RestrictionManagerTest.kt index e253f253d..b764f1ae0 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/restriction/RestrictionManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/restriction/RestrictionManagerTest.kt @@ -7,7 +7,7 @@ import android.os.Bundle import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState -import com.x8bit.bitwarden.data.platform.manager.util.FakeAppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.util.FakeAppStateManager import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import io.mockk.clearMocks @@ -27,7 +27,7 @@ class RestrictionManagerTest { every { registerReceiver(any(), any()) } returns null every { unregisterReceiver(any()) } just runs } - private val fakeAppForegroundManager = FakeAppForegroundManager() + private val fakeAppStateManager = FakeAppStateManager() private val fakeDispatcherManager = FakeDispatcherManager().apply { setMain(unconfined) } @@ -35,7 +35,7 @@ class RestrictionManagerTest { private val restrictionsManager = mockk() private val restrictionManager: RestrictionManager = RestrictionManagerImpl( - appForegroundManager = fakeAppForegroundManager, + appStateManager = fakeAppStateManager, dispatcherManager = fakeDispatcherManager, context = context, environmentRepository = fakeEnvironmentRepository, @@ -51,7 +51,7 @@ class RestrictionManagerTest { fun `on app foreground with a null bundle should register receiver and do nothing else`() { every { restrictionsManager.applicationRestrictions } returns null - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED verify(exactly = 1) { context.registerReceiver(any(), any()) @@ -63,7 +63,7 @@ class RestrictionManagerTest { fun `on app foreground with an empty bundle should register receiver and do nothing else`() { every { restrictionsManager.applicationRestrictions } returns mockBundle() - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED verify(exactly = 1) { context.registerReceiver(any(), any()) @@ -78,7 +78,7 @@ class RestrictionManagerTest { restrictionsManager.applicationRestrictions } returns mockBundle("key" to "unknown") - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED verify(exactly = 1) { context.registerReceiver(any(), any()) @@ -93,7 +93,7 @@ class RestrictionManagerTest { restrictionsManager.applicationRestrictions } returns mockBundle("baseEnvironmentUrl" to "https://vault.bitwarden.com") - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED verify(exactly = 1) { context.registerReceiver(any(), any()) @@ -109,7 +109,7 @@ class RestrictionManagerTest { restrictionsManager.applicationRestrictions } returns mockBundle("baseEnvironmentUrl" to baseUrl) - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED verify(exactly = 1) { context.registerReceiver(any(), any()) @@ -130,7 +130,7 @@ class RestrictionManagerTest { restrictionsManager.applicationRestrictions } returns mockBundle("baseEnvironmentUrl" to "https://vault.bitwarden.eu") - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED verify(exactly = 1) { context.registerReceiver(any(), any()) @@ -147,7 +147,7 @@ class RestrictionManagerTest { restrictionsManager.applicationRestrictions } returns mockBundle("baseEnvironmentUrl" to baseUrl) - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED verify(exactly = 1) { context.registerReceiver(any(), any()) @@ -172,7 +172,7 @@ class RestrictionManagerTest { restrictionsManager.applicationRestrictions } returns mockBundle("baseEnvironmentUrl" to baseUrl) - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED verify(exactly = 1) { context.registerReceiver(any(), any()) @@ -192,7 +192,7 @@ class RestrictionManagerTest { restrictionsManager.applicationRestrictions } returns mockBundle("baseEnvironmentUrl" to baseUrl) - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED verify(exactly = 1) { context.registerReceiver(any(), any()) @@ -207,7 +207,7 @@ class RestrictionManagerTest { @Test fun `on app background when not foregrounded should do nothing`() { - fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.BACKGROUNDED verify(exactly = 0) { context.unregisterReceiver(any()) @@ -218,10 +218,10 @@ class RestrictionManagerTest { @Test fun `on app background after foreground should unregister receiver`() { every { restrictionsManager.applicationRestrictions } returns null - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED clearMocks(context, restrictionsManager, answers = false) - fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.BACKGROUNDED verify(exactly = 1) { context.unregisterReceiver(any()) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/FakeAppForegroundManager.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/FakeAppStateManager.kt similarity index 52% rename from app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/FakeAppForegroundManager.kt rename to app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/FakeAppStateManager.kt index 2bd8e637a..74a7ee939 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/FakeAppForegroundManager.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/FakeAppStateManager.kt @@ -1,20 +1,34 @@ package com.x8bit.bitwarden.data.platform.manager.util -import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.AppStateManager +import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow /** - * A faked implementation of [AppForegroundManager] + * A faked implementation of [AppStateManager] */ -class FakeAppForegroundManager : AppForegroundManager { +class FakeAppStateManager : AppStateManager { + private val mutableAppCreationStateFlow = MutableStateFlow(AppCreationState.DESTROYED) private val mutableAppForegroundStateFlow = MutableStateFlow(AppForegroundState.BACKGROUNDED) + override val appCreatedStateFlow: StateFlow + get() = mutableAppCreationStateFlow.asStateFlow() + override val appForegroundStateFlow: StateFlow get() = mutableAppForegroundStateFlow.asStateFlow() + /** + * The current [AppCreationState] tracked by the [appCreatedStateFlow]. + */ + var appCreationState: AppCreationState + get() = mutableAppCreationStateFlow.value + set(value) { + mutableAppCreationStateFlow.value = value + } + /** * The current [AppForegroundState] tracked by the [appForegroundStateFlow]. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt index afc420842..92784cfc5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt @@ -15,8 +15,9 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.manager.model.LogoutEvent import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager +import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState -import com.x8bit.bitwarden.data.platform.manager.util.FakeAppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.util.FakeAppStateManager import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction @@ -53,7 +54,7 @@ import org.junit.jupiter.api.Test @Suppress("LargeClass") class VaultLockManagerTest { private val fakeAuthDiskSource = FakeAuthDiskSource() - private val fakeAppForegroundManager = FakeAppForegroundManager() + private val fakeAppStateManager = FakeAppStateManager() private val authSdkSource: AuthSdkSource = mockk { coEvery { hashPassword( @@ -90,7 +91,7 @@ class VaultLockManagerTest { authSdkSource = authSdkSource, vaultSdkSource = vaultSdkSource, settingsRepository = settingsRepository, - appForegroundManager = fakeAppForegroundManager, + appStateManager = fakeAppStateManager, userLogoutManager = userLogoutManager, trustedDeviceManager = trustedDeviceManager, dispatcherManager = fakeDispatcherManager, @@ -147,18 +148,86 @@ class VaultLockManagerTest { } @Test - fun `app coming into background subsequent times should perform timeout action if necessary`() { + fun `app being destroyed should perform timeout action if necessary`() { setAccountTokens() fakeAuthDiskSource.userState = MOCK_USER_STATE - // Start in a foregrounded state - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED - // Will be used within each loop to reset the test to a suitable initial state. fun resetTest(vaultTimeout: VaultTimeout) { clearVerifications(userLogoutManager) mutableVaultTimeoutStateFlow.value = vaultTimeout - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appCreationState = AppCreationState.CREATED + verifyUnlockedVaultBlocking(userId = USER_ID) + assertTrue(vaultLockManager.isVaultUnlocked(USER_ID)) + } + + // Test Lock action + mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOCK + MOCK_TIMEOUTS.forEach { vaultTimeout -> + resetTest(vaultTimeout = vaultTimeout) + fakeAppStateManager.appCreationState = AppCreationState.DESTROYED + + when (vaultTimeout) { + VaultTimeout.FifteenMinutes, + VaultTimeout.ThirtyMinutes, + VaultTimeout.OneHour, + VaultTimeout.FourHours, + is VaultTimeout.Custom, + VaultTimeout.Immediately, + VaultTimeout.OneMinute, + VaultTimeout.FiveMinutes, + VaultTimeout.OnAppRestart, + -> { + assertFalse(vaultLockManager.isVaultUnlocked(USER_ID)) + } + + VaultTimeout.Never -> { + assertTrue(vaultLockManager.isVaultUnlocked(USER_ID)) + } + } + verify(exactly = 0) { userLogoutManager.softLogout(any()) } + } + + // Test Logout action + mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOGOUT + MOCK_TIMEOUTS.forEach { vaultTimeout -> + resetTest(vaultTimeout = vaultTimeout) + fakeAppStateManager.appCreationState = AppCreationState.DESTROYED + + when (vaultTimeout) { + VaultTimeout.OnAppRestart, + VaultTimeout.FifteenMinutes, + VaultTimeout.ThirtyMinutes, + VaultTimeout.OneHour, + VaultTimeout.FourHours, + is VaultTimeout.Custom, + VaultTimeout.Immediately, + VaultTimeout.OneMinute, + VaultTimeout.FiveMinutes, + -> { + verify(exactly = 1) { userLogoutManager.softLogout(any()) } + } + + VaultTimeout.Never -> { + verify(exactly = 0) { userLogoutManager.softLogout(any()) } + } + } + } + } + + @Test + fun `app coming into background subsequent times should perform timeout action if necessary`() { + setAccountTokens() + fakeAuthDiskSource.userState = MOCK_USER_STATE + + // Start in a foregrounded state + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED + + // Will be used within each loop to reset the test to a suitable initial state. + fun resetTest(vaultTimeout: VaultTimeout) { + clearVerifications(userLogoutManager) + mutableVaultTimeoutStateFlow.value = vaultTimeout + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED verifyUnlockedVaultBlocking(userId = USER_ID) assertTrue(vaultLockManager.isVaultUnlocked(USER_ID)) } @@ -168,7 +237,7 @@ class VaultLockManagerTest { MOCK_TIMEOUTS.forEach { vaultTimeout -> resetTest(vaultTimeout = vaultTimeout) - fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.BACKGROUNDED // Advance by 6 minutes. Only actions with a timeout less than this will be triggered. testDispatcher.scheduler.advanceTimeBy(delayTimeMillis = 6 * 60 * 1000L) @@ -201,7 +270,7 @@ class VaultLockManagerTest { mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOGOUT MOCK_TIMEOUTS.forEach { vaultTimeout -> resetTest(vaultTimeout = vaultTimeout) - fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.BACKGROUNDED // Advance by 6 minutes. Only actions with a timeout less than this will be triggered. testDispatcher.scheduler.advanceTimeBy(delayTimeMillis = 6 * 60 * 1000L) @@ -236,11 +305,11 @@ class VaultLockManagerTest { mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOCK mutableVaultTimeoutStateFlow.value = VaultTimeout.Never - fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.BACKGROUNDED verifyUnlockedVaultBlocking(userId = USER_ID) assertTrue(vaultLockManager.isVaultUnlocked(USER_ID)) - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED assertTrue(vaultLockManager.isVaultUnlocked(USER_ID)) } @@ -253,11 +322,11 @@ class VaultLockManagerTest { mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOCK mutableVaultTimeoutStateFlow.value = VaultTimeout.OnAppRestart - fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.BACKGROUNDED verifyUnlockedVaultBlocking(userId = USER_ID) assertTrue(vaultLockManager.isVaultUnlocked(USER_ID)) - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED assertFalse(vaultLockManager.isVaultUnlocked(USER_ID)) } @@ -269,11 +338,11 @@ class VaultLockManagerTest { mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOCK mutableVaultTimeoutStateFlow.value = VaultTimeout.ThirtyMinutes - fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.BACKGROUNDED verifyUnlockedVaultBlocking(userId = USER_ID) assertTrue(vaultLockManager.isVaultUnlocked(USER_ID)) - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED assertFalse(vaultLockManager.isVaultUnlocked(USER_ID)) } @@ -286,7 +355,7 @@ class VaultLockManagerTest { mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOCK mutableVaultTimeoutStateFlow.value = VaultTimeout.ThirtyMinutes - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED assertFalse(vaultLockManager.isVaultUnlocked(USER_ID)) } @@ -298,11 +367,11 @@ class VaultLockManagerTest { mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOGOUT mutableVaultTimeoutStateFlow.value = VaultTimeout.ThirtyMinutes - fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.BACKGROUNDED verifyUnlockedVaultBlocking(userId = USER_ID) assertTrue(vaultLockManager.isVaultUnlocked(USER_ID)) - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED verify(exactly = 1) { settingsRepository.getVaultTimeoutActionStateFlow(USER_ID) } } @@ -314,11 +383,11 @@ class VaultLockManagerTest { mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOGOUT mutableVaultTimeoutStateFlow.value = VaultTimeout.ThirtyMinutes - fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.BACKGROUNDED verifyUnlockedVaultBlocking(userId = USER_ID) assertTrue(vaultLockManager.isVaultUnlocked(USER_ID)) - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED verify(exactly = 0) { settingsRepository.getVaultTimeoutActionStateFlow(USER_ID) } } @@ -329,14 +398,14 @@ class VaultLockManagerTest { fakeAuthDiskSource.userState = MOCK_USER_STATE // Start in a foregrounded state - fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.BACKGROUNDED // We want to skip the first time since that is different from subsequent foregrounds - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED // Will be used within each loop to reset the test to a suitable initial state. fun resetTest(vaultTimeout: VaultTimeout) { mutableVaultTimeoutStateFlow.value = vaultTimeout - fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.BACKGROUNDED clearVerifications(userLogoutManager) verifyUnlockedVaultBlocking(userId = USER_ID) assertTrue(vaultLockManager.isVaultUnlocked(USER_ID)) @@ -347,7 +416,7 @@ class VaultLockManagerTest { MOCK_TIMEOUTS.forEach { vaultTimeout -> resetTest(vaultTimeout = vaultTimeout) - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED // Advance by 6 minutes. Only actions with a timeout less than this will be triggered. testDispatcher.scheduler.advanceTimeBy(delayTimeMillis = 6 * 60 * 1000L) @@ -361,7 +430,7 @@ class VaultLockManagerTest { MOCK_TIMEOUTS.forEach { vaultTimeout -> resetTest(vaultTimeout = vaultTimeout) - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED // Advance by 6 minutes. Only actions with a timeout less than this will be triggered. testDispatcher.scheduler.advanceTimeBy(delayTimeMillis = 6 * 60 * 1000L) @@ -376,7 +445,7 @@ class VaultLockManagerTest { fun `switching users should perform lock actions or start a timer for each user if necessary`() { val userId2 = "mockId-2" setAccountTokens(listOf(USER_ID, userId2)) - fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED + fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED fakeAuthDiskSource.userState = UserStateJson( activeUserId = USER_ID, accounts = mapOf( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 22321beb2..281f0bbd2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,7 +46,7 @@ Note that these data sources are constructed in a manner that adheres to a very ### Managers -Manager classes represent something of a middle level of the data layer. While some manager classes like [VaultLockManager](../app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManager.kt) depend on the the lower-level data sources, others are wrappers around OS-level classes (ex: [AppForegroundManager](../app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManager.kt)) while others have no dependencies at all (ex: [SpecialCircumstanceManager](../app/src/main/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManager.kt)). The commonality amongst the manager classes is that they tend to have a single discrete responsibility. These classes may also exist solely in the data layer for use inside a repository or manager class, like [AppForegroundManager](../app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManager.kt), or may be exposed directly to the UI layer, like [SpecialCircumstanceManager](../app/src/main/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManager.kt). +Manager classes represent something of a middle level of the data layer. While some manager classes like [VaultLockManager](../app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManager.kt) depend on the the lower-level data sources, others are wrappers around OS-level classes (ex: [AppStateManager](../app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppStateManager.kt)) while others have no dependencies at all (ex: [SpecialCircumstanceManager](../app/src/main/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManager.kt)). The commonality amongst the manager classes is that they tend to have a single discrete responsibility. These classes may also exist solely in the data layer for use inside a repository or manager class, like [AppStateManager](../app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppStateManager.kt), or may be exposed directly to the UI layer, like [SpecialCircumstanceManager](../app/src/main/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManager.kt). ### Repositories From e397c036e49ad4e443d13c7729e217f9fb071ed9 Mon Sep 17 00:00:00 2001 From: Dave Severns <149429124+dseverns-livefront@users.noreply.github.com> Date: Wed, 6 Nov 2024 14:39:55 -0500 Subject: [PATCH 8/9] PM-14353 : Clean up consumed snackbar on quick resubmission due to state based nav. (#4235) --- .../manager/snackbar/SnackbarRelayManager.kt | 5 +++ .../snackbar/SnackbarRelayManagerImpl.kt | 6 +++ .../ui/vault/feature/vault/VaultViewModel.kt | 5 ++- .../snackbar/SnackbarRelayManagerTest.kt | 38 ++++++++++++++----- .../vault/feature/vault/VaultViewModelTest.kt | 31 ++++++++++++--- 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManager.kt index cf341c3c4..1b7e809b9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManager.kt @@ -19,4 +19,9 @@ interface SnackbarRelayManager { * the [relay] to receive the data from. */ fun getSnackbarDataFlow(relay: SnackbarRelay): Flow + + /** + * Clears the buffer for the given [relay]. + */ + fun clearRelayBuffer(relay: SnackbarRelay) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerImpl.kt index 83cfae46f..22ef37765 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerImpl.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.manager.snackbar import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.filterNotNull @@ -26,6 +27,11 @@ class SnackbarRelayManagerImpl : SnackbarRelayManager { } .filterNotNull() + @OptIn(ExperimentalCoroutinesApi::class) + override fun clearRelayBuffer(relay: SnackbarRelay) { + getSnackbarDataFlowInternal(relay).resetReplayCache() + } + private fun getSnackbarDataFlowInternal( relay: SnackbarRelay, ): MutableSharedFlow = diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 99b7e710c..dff036ac9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -73,8 +73,8 @@ class VaultViewModel @Inject constructor( private val settingsRepository: SettingsRepository, private val vaultRepository: VaultRepository, private val firstTimeActionManager: FirstTimeActionManager, + private val snackbarRelayManager: SnackbarRelayManager, featureFlagManager: FeatureFlagManager, - snackbarRelayManager: SnackbarRelayManager, ) : BaseViewModel( initialState = run { val userState = requireNotNull(authRepository.userStateFlow.value) @@ -283,6 +283,9 @@ class VaultViewModel @Inject constructor( SwitchAccountResult.AccountSwitched -> true SwitchAccountResult.NoChange -> false } + if (isSwitchingAccounts) { + snackbarRelayManager.clearRelayBuffer(SnackbarRelay.MY_VAULT_RELAY) + } mutableStateFlow.update { it.copy(isSwitchingAccounts = isSwitchingAccounts) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerTest.kt index 49e6639c2..46d821325 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerTest.kt @@ -66,16 +66,16 @@ class SnackbarRelayManagerTest { fun `When multiple consumers are registered to the same relay, send data to all consumers`() = runTest { val relayManager = SnackbarRelayManagerImpl() - val relay1 = SnackbarRelay.MY_VAULT_RELAY + val relay = SnackbarRelay.MY_VAULT_RELAY val expectedData = BitwardenSnackbarData(message = "Test message".asText()) turbineScope { - val consumer1 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope) - relayManager.sendSnackbarData(data = expectedData, relay = relay1) + val consumer1 = relayManager.getSnackbarDataFlow(relay).testIn(backgroundScope) + relayManager.sendSnackbarData(data = expectedData, relay = relay) assertEquals( expectedData, consumer1.awaitItem(), ) - val consumer2 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope) + val consumer2 = relayManager.getSnackbarDataFlow(relay).testIn(backgroundScope) assertEquals( expectedData, consumer2.awaitItem(), @@ -85,20 +85,40 @@ class SnackbarRelayManagerTest { @Suppress("MaxLineLength") @Test - fun `When multiple consumers are register to the same relay, and one is completed before the other the second consumer registers should not receive any emissions`() = + fun `When multiple consumers are registered to the same relay, and one is completed before the other the second consumer registers should not receive any emissions`() = runTest { val relayManager = SnackbarRelayManagerImpl() - val relay1 = SnackbarRelay.MY_VAULT_RELAY + val relay = SnackbarRelay.MY_VAULT_RELAY val expectedData = BitwardenSnackbarData(message = "Test message".asText()) turbineScope { - val consumer1 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope) - relayManager.sendSnackbarData(data = expectedData, relay = relay1) + val consumer1 = relayManager.getSnackbarDataFlow(relay).testIn(backgroundScope) + relayManager.sendSnackbarData(data = expectedData, relay = relay) assertEquals( expectedData, consumer1.awaitItem(), ) consumer1.cancel() - val consumer2 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope) + val consumer2 = relayManager.getSnackbarDataFlow(relay).testIn(backgroundScope) + consumer2.expectNoEvents() + } + } + + @Suppress("MaxLineLength") + @Test + fun `When multiple consumers register to the same relay, and clearRelayBuffer is called, the second consumer should not receive any emissions`() = + runTest { + val relayManager = SnackbarRelayManagerImpl() + val relay = SnackbarRelay.MY_VAULT_RELAY + val expectedData = BitwardenSnackbarData(message = "Test message".asText()) + turbineScope { + val consumer1 = relayManager.getSnackbarDataFlow(relay).testIn(backgroundScope) + relayManager.sendSnackbarData(data = expectedData, relay = relay) + assertEquals( + expectedData, + consumer1.awaitItem(), + ) + relayManager.clearRelayBuffer(relay) + val consumer2 = relayManager.getSnackbarDataFlow(relay).testIn(backgroundScope) consumer2.expectNoEvents() } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index fc86a0cf2..f5d4fda3f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -36,7 +36,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay -import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManagerImpl +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType @@ -49,6 +49,7 @@ import io.mockk.mockk import io.mockk.runs import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals @@ -66,7 +67,12 @@ class VaultViewModelTest : BaseViewModelTest() { ZoneOffset.UTC, ) - private val snackbarRelayManager = SnackbarRelayManagerImpl() + private val mutableSnackbarDataFlow = MutableStateFlow(null) + private val snackbarRelayManager: SnackbarRelayManager = mockk { + every { getSnackbarDataFlow(SnackbarRelay.MY_VAULT_RELAY) } returns mutableSnackbarDataFlow + .filterNotNull() + every { clearRelayBuffer(SnackbarRelay.MY_VAULT_RELAY) } just runs + } private val clipboardManager: BitwardenClipboardManager = mockk { every { setText(any()) } just runs @@ -1802,15 +1808,28 @@ class VaultViewModelTest : BaseViewModelTest() { fun `when SnackbarRelay flow updates, snackbar is shown`() = runTest { val viewModel = createViewModel() val expectedSnackbarData = BitwardenSnackbarData(message = "test message".asText()) + mutableSnackbarDataFlow.update { expectedSnackbarData } viewModel.eventFlow.test { - snackbarRelayManager.sendSnackbarData( - data = expectedSnackbarData, - relay = SnackbarRelay.MY_VAULT_RELAY, - ) assertEquals(VaultEvent.ShowSnackbar(expectedSnackbarData), awaitItem()) } } + @Test + fun `when account switch action is handled, clear snackbar relay buffer should be called`() = + runTest { + val viewModel = createViewModel() + switchAccountResult = SwitchAccountResult.AccountSwitched + viewModel.trySendAction( + VaultAction.SwitchAccountClick( + accountSummary = mockk() { + every { userId } returns "updatedUserId" + }, + ), + ) + verify(exactly = 1) { + snackbarRelayManager.clearRelayBuffer(SnackbarRelay.MY_VAULT_RELAY) + } + } private fun createViewModel(): VaultViewModel = VaultViewModel( authRepository = authRepository, From 87d324b0633bc3a32f21ba17f601ff743d419cef Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Wed, 6 Nov 2024 18:42:06 -0500 Subject: [PATCH 9/9] [PM-12922] Disable delete if user can't manage collection (#4179) --- .../feature/addedit/VaultAddEditScreen.kt | 2 +- .../feature/addedit/VaultAddEditViewModel.kt | 26 +- .../addedit/util/CipherViewExtensions.kt | 4 +- .../ui/vault/feature/item/VaultItemScreen.kt | 3 +- .../vault/feature/item/VaultItemViewModel.kt | 31 ++- .../feature/item/model/VaultItemStateData.kt | 1 + .../feature/item/util/CipherViewExtensions.kt | 4 +- .../sdk/model/CollectionViewUtil.kt | 8 +- .../addedit/VaultAddEditViewModelTest.kt | 140 ++++++++++ .../addedit/util/CipherViewExtensionsTest.kt | 7 + .../vault/feature/item/VaultItemScreenTest.kt | 28 +- .../feature/item/VaultItemViewModelTest.kt | 247 ++++++++++++++++++ .../item/util/CipherViewExtensionsTest.kt | 13 + .../feature/item/util/VaultItemTestUtil.kt | 2 + 14 files changed, 503 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt index 2bf3d550c..23a778d30 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt @@ -314,7 +314,7 @@ fun VaultAddEditScreen( text = stringResource(id = R.string.delete), onClick = { pendingDeleteCipher = true }, ) - .takeUnless { state.isAddItemMode }, + .takeUnless { state.isAddItemMode || !state.canDelete }, ), ) }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index fd7dfb4ae..c33374100 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -149,7 +149,9 @@ class VaultAddEditViewModel @Inject constructor( attestationOptions = fido2AttestationOptions, isIndividualVaultDisabled = isIndividualVaultDisabled, ) - ?: totpData?.toDefaultAddTypeContent(isIndividualVaultDisabled) + ?: totpData?.toDefaultAddTypeContent( + isIndividualVaultDisabled = isIndividualVaultDisabled, + ) ?: VaultAddEditState.ViewState.Content( common = VaultAddEditState.ViewState.Content.Common(), isIndividualVaultDisabled = isIndividualVaultDisabled, @@ -1589,6 +1591,16 @@ class VaultAddEditViewModel @Inject constructor( currentAccount = userData?.activeAccount, vaultAddEditType = vaultAddEditType, ) { currentAccount, cipherView -> + val canDelete = vaultData.collectionViewList + .none { + val itemIsInCollection = cipherView + ?.collectionIds + ?.contains(it.id) == true + + val canManageCollection = it.manage + + itemIsInCollection && !canManageCollection + } // Derive the view state from the current Cipher for Edit mode // or use the current state for Add (cipherView @@ -1598,6 +1610,7 @@ class VaultAddEditViewModel @Inject constructor( totpData = totpData, resourceManager = resourceManager, clock = clock, + canDelete = canDelete, ) ?: viewState) .appendFolderAndOwnerData( @@ -2026,6 +2039,15 @@ data class VaultAddEditState( */ val isCloneMode: Boolean get() = vaultAddEditType is VaultAddEditType.CloneItem + /** + * Helper to determine if the UI should allow deletion of this item. + */ + val canDelete: Boolean + get() = (viewState as? ViewState.Content) + ?.common + ?.canDelete + ?: false + /** * Enum representing the main type options for the vault, such as LOGIN, CARD, etc. * @@ -2085,6 +2107,7 @@ data class VaultAddEditState( * @property selectedOwnerId The ID of the owner associated with the item. * @property availableOwners A list of available owners. * @property hasOrganizations Indicates if the user is part of any organizations. + * @property canDelete Indicates whether the current user can delete the item. */ @Parcelize data class Common( @@ -2101,6 +2124,7 @@ data class VaultAddEditState( val selectedOwnerId: String? = null, val availableOwners: List = emptyList(), val hasOrganizations: Boolean = false, + val canDelete: Boolean = true, ) : Parcelable { /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt index a68834588..c401ee77c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt @@ -35,13 +35,14 @@ private const val PASSKEY_CREATION_TIME_PATTERN: String = "hh:mm a" /** * Transforms [CipherView] into [VaultAddEditState.ViewState]. */ -@Suppress("LongMethod") +@Suppress("LongMethod", "LongParameterList") fun CipherView.toViewState( isClone: Boolean, isIndividualVaultDisabled: Boolean, totpData: TotpData?, resourceManager: ResourceManager, clock: Clock, + canDelete: Boolean, ): VaultAddEditState.ViewState = VaultAddEditState.ViewState.Content( type = when (type) { @@ -108,6 +109,7 @@ fun CipherView.toViewState( availableOwners = emptyList(), hasOrganizations = false, customFieldData = this.fields.orEmpty().map { it.toCustomField() }, + canDelete = canDelete, ), isIndividualVaultDisabled = isIndividualVaultDisabled, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index 63011234f..c16a9a3de 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -226,7 +226,8 @@ fun VaultItemScreen( ) } }, - ), + ) + .takeIf { state.canDelete }, ), ) }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index b3623dbdc..29bb1affb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -82,7 +82,8 @@ class VaultItemViewModel @Inject constructor( vaultRepository.getVaultItemStateFlow(state.vaultItemId), authRepository.userStateFlow, vaultRepository.getAuthCodeFlow(state.vaultItemId), - ) { cipherViewState, userState, authCodeState -> + vaultRepository.collectionsStateFlow, + ) { cipherViewState, userState, authCodeState, collectionsState -> val totpCodeData = authCodeState.data?.let { TotpCodeItemData( periodSeconds = it.periodSeconds, @@ -96,14 +97,30 @@ class VaultItemViewModel @Inject constructor( vaultDataState = combineDataStates( cipherViewState, authCodeState, - ) { _, _ -> + collectionsState, + ) { _, _, _ -> // We are only combining the DataStates to know the overall state, // we map it to the appropriate value below. } .mapNullable { + // Deletion is not allowed when the item is in a collection that the user + // does not have "manage" permission for. + val canDelete = collectionsState.data + ?.none { + val itemIsInCollection = cipherViewState.data + ?.collectionIds + ?.contains(it.id) == true + + val canManageCollection = it.manage + + itemIsInCollection && !canManageCollection + } + ?: true + VaultItemStateData( cipher = cipherViewState.data, totpCodeItemData = totpCodeData, + canDelete = canDelete, ) }, ) @@ -915,6 +932,7 @@ class VaultItemViewModel @Inject constructor( isPremiumUser = account.isPremium, hasMasterPassword = account.hasMasterPassword, totpCodeItemData = this.data?.totpCodeItemData, + canDelete = this.data?.canDelete == true, ) ?: VaultItemState.ViewState.Error(message = errorText) @@ -1153,6 +1171,14 @@ data class VaultItemState( ?.isNotEmpty() ?: false + /** + * Whether or not the cipher can be deleted. + */ + val canDelete: Boolean + get() = viewState.asContentOrNull() + ?.common + ?.canDelete == true + /** * The text to display on the deletion confirmation dialog. */ @@ -1216,6 +1242,7 @@ data class VaultItemState( @IgnoredOnParcel val currentCipher: CipherView? = null, val attachments: List?, + val canDelete: Boolean, ) : Parcelable { /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/model/VaultItemStateData.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/model/VaultItemStateData.kt index 91d3ba2a5..1f7c9d43b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/model/VaultItemStateData.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/model/VaultItemStateData.kt @@ -11,4 +11,5 @@ import com.bitwarden.vault.CipherView data class VaultItemStateData( val cipher: CipherView?, val totpCodeItemData: TotpCodeItemData?, + val canDelete: Boolean, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt index e4c46f592..565f600a9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt @@ -32,13 +32,14 @@ private const val FIDO2_CREDENTIAL_CREATION_TIME_PATTERN: String = "h:mm a" /** * Transforms [VaultData] into [VaultState.ViewState]. */ -@Suppress("CyclomaticComplexMethod", "LongMethod") +@Suppress("CyclomaticComplexMethod", "LongMethod", "LongParameterList") fun CipherView.toViewState( previousState: VaultItemState.ViewState.Content?, isPremiumUser: Boolean, hasMasterPassword: Boolean, totpCodeItemData: TotpCodeItemData?, clock: Clock = Clock.systemDefaultZone(), + canDelete: Boolean, ): VaultItemState.ViewState = VaultItemState.ViewState.Content( common = VaultItemState.ViewState.Content.Common( @@ -79,6 +80,7 @@ fun CipherView.toViewState( } } .orEmpty(), + canDelete = canDelete, ), type = when (type) { CipherType.LOGIN -> { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CollectionViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CollectionViewUtil.kt index 379ad075f..5d3e216f4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CollectionViewUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CollectionViewUtil.kt @@ -5,7 +5,11 @@ import com.bitwarden.vault.CollectionView /** * Create a mock [CollectionView] with a given [number]. */ -fun createMockCollectionView(number: Int, name: String? = null): CollectionView = +fun createMockCollectionView( + number: Int, + name: String? = null, + manage: Boolean = true, +): CollectionView = CollectionView( id = "mockId-$number", organizationId = "mockOrganizationId-$number", @@ -13,5 +17,5 @@ fun createMockCollectionView(number: Int, name: String? = null): CollectionView name = name ?: "mockName-$number", externalId = "mockExternalId-$number", readOnly = false, - manage = true, + manage = manage, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index e62191f8a..1347c36c1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -45,6 +45,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult @@ -1195,6 +1196,136 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `in edit mode, canDelete should be false when cipher is in a collection the user cannot manage`() = + runTest { + val cipherView = createMockCipherView(1) + val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) + val stateWithName = createVaultAddItemState( + vaultAddEditType = vaultAddEditType, + commonContentViewState = createCommonContentViewState( + name = "mockName-1", + originalCipher = cipherView, + customFieldData = listOf( + VaultAddEditState.Custom.HiddenField( + itemId = "testId", + name = "mockName-1", + value = "mockValue-1", + ), + ), + notes = "mockNotes-1", + canDelete = false, + ), + ) + + every { + cipherView.toViewState( + isClone = false, + isIndividualVaultDisabled = false, + totpData = null, + resourceManager = resourceManager, + clock = fixedClock, + canDelete = false, + ) + } returns stateWithName.viewState + + mutableVaultDataFlow.value = DataState.Loaded( + data = createVaultData( + cipherView = cipherView, + collectionViewList = listOf( + createMockCollectionView( + number = 1, + manage = false, + ), + ), + ), + ) + + createAddVaultItemViewModel( + createSavedStateHandleWithState( + state = stateWithName, + vaultAddEditType = vaultAddEditType, + ), + ) + + verify { + cipherView.toViewState( + isClone = false, + isIndividualVaultDisabled = false, + totpData = null, + resourceManager = resourceManager, + clock = fixedClock, + canDelete = false, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in edit mode, canDelete should be true when cipher is in a collection the user can manage`() = + runTest { + val cipherView = createMockCipherView(1) + val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) + val stateWithName = createVaultAddItemState( + vaultAddEditType = vaultAddEditType, + commonContentViewState = createCommonContentViewState( + name = "mockName-1", + originalCipher = cipherView, + customFieldData = listOf( + VaultAddEditState.Custom.HiddenField( + itemId = "testId", + name = "mockName-1", + value = "mockValue-1", + ), + ), + notes = "mockNotes-1", + canDelete = true, + ), + ) + + every { + cipherView.toViewState( + isClone = false, + isIndividualVaultDisabled = false, + totpData = null, + resourceManager = resourceManager, + clock = fixedClock, + canDelete = true, + ) + } returns stateWithName.viewState + + mutableVaultDataFlow.value = DataState.Loaded( + data = createVaultData( + cipherView = cipherView, + collectionViewList = listOf( + createMockCollectionView( + number = 1, + manage = true, + ), + ), + ), + ) + + createAddVaultItemViewModel( + createSavedStateHandleWithState( + state = stateWithName, + vaultAddEditType = vaultAddEditType, + ), + ) + + verify { + cipherView.toViewState( + isClone = false, + isIndividualVaultDisabled = false, + totpData = null, + resourceManager = resourceManager, + clock = fixedClock, + canDelete = true, + ) + } + } + @Test fun `in edit mode, updateCipher success should ShowToast and NavigateBack`() = runTest { @@ -1310,6 +1441,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) } returns stateWithName.viewState mutableVaultDataFlow.value = DataState.Loaded( @@ -1341,6 +1473,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any()) } @@ -1375,6 +1508,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) } returns stateWithName.viewState coEvery { @@ -1437,6 +1571,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) } returns stateWithName.viewState coEvery { @@ -1502,6 +1637,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) } returns stateWithName.viewState mutableVaultDataFlow.value = DataState.Loaded( @@ -1558,6 +1694,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) } returns stateWithName.viewState every { fido2CredentialManager.isUserVerified } returns true @@ -1619,6 +1756,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) } returns stateWithName.viewState every { fido2CredentialManager.isUserVerified } returns false @@ -3982,6 +4120,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { availableOwners: List = createOwnerList(), selectedOwnerId: String? = null, hasOrganizations: Boolean = true, + canDelete: Boolean = true, ): VaultAddEditState.ViewState.Content.Common = VaultAddEditState.ViewState.Content.Common( name = name, @@ -3995,6 +4134,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { availableFolders = availableFolders, availableOwners = availableOwners, hasOrganizations = hasOrganizations, + canDelete = canDelete, ) @Suppress("LongParameterList") diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt index 0d6328799..a2ddb3963 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt @@ -75,6 +75,7 @@ class CipherViewExtensionsTest { totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( @@ -121,6 +122,7 @@ class CipherViewExtensionsTest { totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( @@ -172,6 +174,7 @@ class CipherViewExtensionsTest { totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( @@ -232,6 +235,7 @@ class CipherViewExtensionsTest { totpData = mockk { every { uri } returns totp }, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( @@ -289,6 +293,7 @@ class CipherViewExtensionsTest { totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( @@ -324,6 +329,7 @@ class CipherViewExtensionsTest { totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( @@ -368,6 +374,7 @@ class CipherViewExtensionsTest { totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index dbcd02847..cb008adb0 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -780,22 +780,39 @@ class VaultItemScreenTest : BaseComposeTest() { } @Test - fun `menu Delete option click should be displayed`() { - - // Confirm dropdown version of item is absent + fun `menu Delete option should be displayed based on state`() { + // Confirm overflow is closed on initial load composeTestRule .onAllNodesWithText("Delete") .filter(hasAnyAncestor(isPopup())) .assertCountEquals(0) + // Open the overflow menu composeTestRule .onNodeWithContentDescription("More") .performClick() - // Click on the delete item in the dropdown + + // Confirm Delete option is present + mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } composeTestRule .onAllNodesWithText("Delete") .filterToOne(hasAnyAncestor(isPopup())) .assertIsDisplayed() + + // Confirm Delete option is not present when canDelete is false + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_LOGIN_VIEW_STATE + .copy( + common = DEFAULT_COMMON + .copy(canDelete = false), + ), + ) + } + composeTestRule + .onAllNodesWithText("Delete") + .filter(hasAnyAncestor(isPopup())) + .assertCountEquals(0) } @Test @@ -1166,6 +1183,7 @@ class VaultItemScreenTest : BaseComposeTest() { @Test fun `Menu should display correct items when cipher is not in a collection`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } composeTestRule .onNodeWithContentDescription("More") .performClick() @@ -2374,6 +2392,7 @@ private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common = title = "test.mp4", ), ), + canDelete = true, ) private val DEFAULT_PASSKEY = R.string.created_xy.asText( @@ -2455,6 +2474,7 @@ private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common = requiresReprompt = true, requiresCloneConfirmation = false, attachments = emptyList(), + canDelete = true, ) private val EMPTY_LOGIN_TYPE: VaultItemState.ViewState.Content.ItemType.Login = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 560de35a1..e351a8281 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -4,6 +4,7 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.vault.CipherView +import com.bitwarden.vault.CollectionView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -17,6 +18,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem import com.x8bit.bitwarden.data.vault.repository.VaultRepository @@ -59,6 +61,8 @@ class VaultItemViewModelTest : BaseViewModelTest() { private val mutableAuthCodeItemFlow = MutableStateFlow>(DataState.Loading) private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val mutableCollectionsStateFlow = + MutableStateFlow>>(DataState.Loading) private val clipboardManager: BitwardenClipboardManager = mockk() private val authRepo: AuthRepository = mockk { @@ -67,6 +71,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { private val vaultRepo: VaultRepository = mockk { every { getAuthCodeFlow(VAULT_ITEM_ID) } returns mutableAuthCodeItemFlow every { getVaultItemStateFlow(VAULT_ITEM_ID) } returns mutableVaultItemFlow + every { collectionsStateFlow } returns mutableCollectionsStateFlow } private val mockFileManager: FileManager = mockk() @@ -151,6 +156,105 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value) } + @Test + fun `canDelete should be true when collections are empty`() = runTest { + val mockCipherView = mockk { + every { + toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = true, + ) + } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + verify { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = true, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `canDelete should be false when cipher is in a collection that the user cannot manage`() = + runTest { + val mockCipherView = mockk { + every { collectionIds } returns listOf("mockId-1", "mockId-2") + every { + toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = false, + ) + } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded( + data = listOf( + createMockCollectionView(number = 1) + .copy(manage = false), + createMockCollectionView(number = 2) + .copy(manage = true), + ), + ) + verify { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = false, + ) + } + } + + @Test + fun `canDelete should be true when cipher is not in collections`() { + val mockCipherView = mockk { + every { collectionIds } returns listOf("mockId-3") + every { + toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = true, + ) + } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded( + data = listOf( + createMockCollectionView(number = 1) + .copy(manage = false), + createMockCollectionView(number = 2) + .copy(manage = false), + ), + ) + verify { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = true, + ) + } + } + @Test fun `DeleteClick should show password dialog when re-prompt is required`() = runTest { @@ -162,11 +266,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.DeleteClick) @@ -185,6 +291,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -204,6 +311,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns loginState } @@ -221,6 +329,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.Common.DeleteClick) assertEquals(expected, viewModel.stateFlow.value) @@ -247,6 +356,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns loginState } @@ -260,6 +370,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.Common.DeleteClick) assertEquals(expected, viewModel.stateFlow.value) @@ -279,12 +390,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val viewModel = createViewModel(state = DEFAULT_STATE) coEvery { @@ -322,11 +435,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val viewModel = createViewModel(state = DEFAULT_STATE) coEvery { @@ -368,12 +483,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val viewModel = createViewModel(state = DEFAULT_STATE) coEvery { @@ -406,11 +523,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) val viewModel = createViewModel(state = loginState) assertEquals(loginState, viewModel.stateFlow.value) @@ -439,12 +558,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns viewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = viewState) val viewModel = createViewModel(state = loginState) assertEquals(loginState, viewModel.stateFlow.value) @@ -476,6 +597,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns DEFAULT_VIEW_STATE } @@ -483,6 +605,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableAuthCodeItemFlow.value = DataState.Loaded( data = createVerificationCodeItem(), ) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val viewModel = createViewModel(state = DEFAULT_STATE) coEvery { @@ -516,11 +639,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val viewModel = createViewModel(state = DEFAULT_STATE) coEvery { @@ -565,11 +690,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) val viewModel = createViewModel(state = loginState) assertEquals(loginState, viewModel.stateFlow.value) @@ -597,11 +724,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -613,6 +742,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } assertEquals( @@ -638,6 +768,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = any(), isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState @@ -648,6 +779,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { } returns ValidatePasswordResult.Success(isValid = true) mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -699,6 +831,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = any(), isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState @@ -709,6 +842,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { } returns ValidatePasswordResult.Success(isValid = false) mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -752,6 +886,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = any(), isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState @@ -762,6 +897,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { } returns ValidatePasswordResult.Error mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -816,12 +952,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.CopyCustomHiddenFieldClick("field")) @@ -839,6 +977,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } @@ -854,6 +993,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns createViewState(common = DEFAULT_COMMON.copy(requiresReprompt = false)) @@ -862,6 +1002,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.Common.CopyCustomHiddenFieldClick(field)) @@ -871,6 +1012,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) organizationEventManager.trackEvent( @@ -910,12 +1052,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -941,6 +1085,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } @@ -974,11 +1119,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -1004,6 +1151,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) organizationEventManager.trackEvent( @@ -1024,12 +1172,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.AttachmentsClick) @@ -1047,6 +1197,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } @@ -1066,12 +1217,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) @@ -1100,12 +1253,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) @@ -1125,6 +1280,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } @@ -1148,12 +1304,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.CloneClick) @@ -1196,12 +1354,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.CloneClick) @@ -1219,6 +1379,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } @@ -1238,12 +1399,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) @@ -1269,12 +1432,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.MoveToOrganizationClick) @@ -1292,6 +1457,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } @@ -1311,12 +1477,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) @@ -1353,12 +1521,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = any(), isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -1409,12 +1579,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = any(), isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -1474,12 +1646,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = any(), isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -1652,6 +1826,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } returns DEFAULT_VIEW_STATE @@ -1659,6 +1834,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) val breachCount = 5 @@ -1692,6 +1868,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } @@ -1710,6 +1887,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } returns DEFAULT_VIEW_STATE @@ -1717,6 +1895,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick) @@ -1736,6 +1915,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } @@ -1750,6 +1930,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } returns createViewState(common = DEFAULT_COMMON.copy(requiresReprompt = false)) @@ -1757,6 +1938,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) every { clipboardManager.setText(text = DEFAULT_LOGIN_PASSWORD) } just runs @@ -1768,6 +1950,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } @@ -1782,6 +1965,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableAuthCodeItemFlow.value = DataState.Loaded( data = createVerificationCodeItem(), ) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyTotpClick) @@ -1808,6 +1992,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } returns createViewState(common = DEFAULT_COMMON.copy(requiresReprompt = false)) @@ -1815,6 +2000,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { every { clipboardManager.setText(text = DEFAULT_LOGIN_USERNAME) } just runs mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUsernameClick) @@ -1825,6 +2011,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } } @@ -1849,12 +2036,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordHistoryClick) @@ -1873,6 +2062,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } } @@ -1888,6 +2078,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } .returns( @@ -1899,6 +2090,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.eventFlow.test { viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordHistoryClick) @@ -1914,6 +2106,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } } @@ -1930,12 +2123,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -1958,6 +2153,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } } @@ -1977,12 +2173,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -2008,6 +2206,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) organizationEventManager.trackEvent( event = OrganizationEvent.CipherClientToggledPasswordVisible( @@ -2042,11 +2241,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns CARD_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(cardState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick) @@ -2067,6 +2268,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2081,6 +2283,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns createViewState( common = DEFAULT_COMMON.copy(requiresReprompt = false), @@ -2090,6 +2293,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { every { clipboardManager.setText(text = "12345436") } just runs mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick) @@ -2100,6 +2304,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2115,11 +2320,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns CARD_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(cardState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -2140,6 +2347,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2154,6 +2362,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns createViewState( common = DEFAULT_COMMON.copy(requiresReprompt = false), @@ -2163,6 +2372,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { every { clipboardManager.setText(text = "12345436") } just runs mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction( VaultItemAction.ItemType.Card.NumberVisibilityClick(isVisible = true), @@ -2179,6 +2389,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2194,11 +2405,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns CARD_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(cardState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) @@ -2219,6 +2432,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2233,6 +2447,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns createViewState( common = DEFAULT_COMMON.copy(requiresReprompt = false), @@ -2242,6 +2457,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { every { clipboardManager.setText(text = "987") } just runs mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) @@ -2252,6 +2468,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2267,11 +2484,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns CARD_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(cardState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -2292,6 +2511,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2306,6 +2526,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns createViewState( common = DEFAULT_COMMON.copy(requiresReprompt = false), @@ -2314,6 +2535,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { } every { clipboardManager.setText(text = "987") } just runs mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) viewModel.trySendAction( @@ -2331,6 +2553,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2359,11 +2582,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns SSH_KEY_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPublicKeyClick) @@ -2387,11 +2612,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns SSH_KEY_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(sshKeyState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -2422,11 +2649,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns SSH_KEY_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyFingerprintClick) @@ -2466,12 +2695,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns viewState } val viewModel = createViewModel(state = null) mutableVaultItemFlow.value = DataState.Loaded(data = cipherView) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(DEFAULT_STATE.copy(viewState = viewState), viewModel.stateFlow.value) } @@ -2481,6 +2712,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { val viewModel = createViewModel(state = null) mutableVaultItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals( DEFAULT_STATE.copy( @@ -2502,12 +2734,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns viewState } val viewModel = createViewModel(state = null) mutableVaultItemFlow.value = DataState.Pending(data = cipherView) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(DEFAULT_STATE.copy(viewState = viewState), viewModel.stateFlow.value) } @@ -2518,6 +2752,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { val viewModel = createViewModel(state = null) mutableVaultItemFlow.value = DataState.Pending(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals( DEFAULT_STATE.copy( @@ -2539,6 +2774,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns viewState } @@ -2575,6 +2811,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns viewState } @@ -2608,6 +2845,15 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } + @Nested + inner class CollectionsFlow { + @BeforeEach + fun setup() { + mutableUserStateFlow.value = DEFAULT_USER_STATE + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + } + } + @Suppress("LongParameterList") private fun createViewModel( state: VaultItemState?, @@ -2780,6 +3026,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { title = "test.mp4", ), ), + canDelete = true, ) private val DEFAULT_VIEW_STATE: VaultItemState.ViewState.Content = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt index b2ab66549..05d86866a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt @@ -45,6 +45,7 @@ class CipherViewExtensionsTest { totpCode = "testCode", ), clock = fixedClock, + canDelete = true, ) assertEquals( @@ -80,6 +81,7 @@ class CipherViewExtensionsTest { totpCode = "testCode", ), clock = fixedClock, + canDelete = true, ) assertEquals( @@ -108,6 +110,7 @@ class CipherViewExtensionsTest { totpCode = "testCode", ), clock = fixedClock, + canDelete = true, ) assertEquals( @@ -136,6 +139,7 @@ class CipherViewExtensionsTest { totpCode = "testCode", ), clock = fixedClock, + canDelete = true, ) assertEquals( @@ -170,6 +174,7 @@ class CipherViewExtensionsTest { totpCode = "testCode", ), clock = fixedClock, + canDelete = true, ) assertEquals( @@ -194,6 +199,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( @@ -216,6 +222,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( @@ -237,6 +244,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( @@ -268,6 +276,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( @@ -304,6 +313,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( @@ -342,6 +352,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( @@ -364,6 +375,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) val expectedState = VaultItemState.ViewState.Content( @@ -384,6 +396,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( VaultItemState.ViewState.Content( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt index 6b0d0c0cb..5fb2db4fe 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt @@ -170,6 +170,7 @@ fun createCommonContent( requiresReprompt = true, requiresCloneConfirmation = false, attachments = emptyList(), + canDelete = true, ) } else { VaultItemState.ViewState.Content.Common( @@ -213,6 +214,7 @@ fun createCommonContent( title = "test.mp4", ), ), + canDelete = true, ) }