Compare commits

...

40 Commits

Author SHA1 Message Date
André Bispo
155d107ce4 [PM-9933] Update copy for marketing emails 2024-07-23 12:12:13 +01:00
André Bispo
3251d776a2 Merge branch 'main' into pm-6702/registration-flows
# Conflicts:
#	app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt
2024-07-23 10:38:55 +01:00
André Bispo
c5df4c115b [PM-6702] Tests code clean up 2024-07-23 10:37:44 +01:00
André Bispo
fd1ecbb759 Merge branch 'main' into pm-6702/registration-flows
# Conflicts:
#	app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
2024-07-22 18:32:43 +01:00
André Bispo
8484d111a6 [PM-6702] Fix typos 2024-07-22 18:00:03 +01:00
André Bispo
fd8068f5f7 [PM-6702] Code format 2024-07-22 17:02:00 +01:00
André Bispo
20b1ac2a79 [PM-6702] Code format 2024-07-22 16:59:43 +01:00
André Bispo
61f7daddf3 [PM-6702] Open email app instead of opening the app on compose email 2024-07-22 16:43:09 +01:00
André Bispo
2cbede24b5 [PM-6702] Refactor timestamp property 2024-07-22 15:08:45 +01:00
André Bispo
cde639de8c [PM-6702] Fix tests of CompleteRegistration 2024-07-22 14:49:06 +01:00
André Bispo
5a225fb786 [PM-6702] Handle no body response from SendVerificationEmail 2024-07-22 14:48:41 +01:00
André Bispo
64af6eac55 [PM-6702] Save the environment in which a user started the registration process. Load the environment to complete registration in that environment. 2024-07-19 20:42:17 +01:00
André Bispo
78031e306f [PM-6702] Fix tests for StartRegistration 2024-07-19 15:24:44 +01:00
André Bispo
45159f2e1b [PM-6702] Fix StartRegistration tests 2024-07-19 12:28:47 +01:00
André Bispo
1ee216cb7d [PM-6702] Sanitize AppLink Uri by removing the fragment char 2024-07-19 12:28:18 +01:00
André Bispo
0c878956d2 [PM-6702] Change CompleteRegistration navigate to login, to navigate to landing 2024-07-19 12:26:03 +01:00
André Bispo
e819043c9e [PM-6702] Remove captcha from StartRegistration 2024-07-19 12:22:05 +01:00
André Bispo
99c496e300 [PM-6702] Move register finish from api to identity 2024-07-19 12:20:53 +01:00
André Bispo
6c78ecf297 [PM-6702] Change send verification email response. 2024-07-11 17:06:40 +01:00
André Bispo
fbbb3379ca [PM-6702] Rename register finish request params 2024-07-11 17:05:14 +01:00
André Bispo
54d232e3b0 [PM-6702] Hide marketing switch if region is self-hosted 2024-07-05 18:47:06 +01:00
André Bispo
bcaeb82623 Merge branch 'main' into pm-6702/registration-flows
# Conflicts:
#	app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt
2024-07-03 11:43:37 +01:00
André Bispo
fa94904028 [PM-6702] Add CheckEmail tests 2024-07-03 11:42:12 +01:00
André Bispo
be5bc73e5b [PM-6702] Add CompleteRegistration tests 2024-07-02 21:13:53 +01:00
André Bispo
b95f456e11 [PM-6702] Add StartRegistration tests 2024-07-02 15:15:22 +01:00
André Bispo
3b93014b70 [PM-6702] Make name optional on start registration 2024-07-02 15:14:44 +01:00
André Bispo
ca3bbb54b7 [PM-6702] Fix unit tests 2024-07-01 11:48:24 +01:00
André Bispo
e5dded89ef Merge branch 'main' into pm-6702/registration-flows
# Conflicts:
#	app/src/main/res/values/strings.xml
2024-06-28 17:30:56 +01:00
André Bispo
de2b374619 [PM-6702] Add updated service calls for start registration and complete registration 2024-06-28 17:29:28 +01:00
André Bispo
68eb8b716c [PM-6702] Add fromEmail parameter from email URL AppLink 2024-06-28 17:28:49 +01:00
André Bispo
75c2f0e97e [Pm-6702] Pass arguments from AppLink to Complete registration. Compute region from url domain 2024-06-25 23:34:11 +01:00
André Bispo
ec8c1e36b6 [Pm-6702] Fix AppLink flow to complete registration 2024-06-24 15:38:12 +01:00
André Bispo
12db5ee610 [PM-6702] Add AppLink support to open links sent to email to complete account registration
(cherry picked from commit b867079ced)
2024-06-22 15:26:41 +01:00
André Bispo
51f9e3f24c [PM-6702] Fix clickable text font weight
(cherry picked from commit 2556217432)
2024-06-22 15:25:14 +01:00
André Bispo
37822f48f5 [PM-6702] Fix password strength indicator component package name
(cherry picked from commit fe9c7794ce)
2024-06-22 15:25:03 +01:00
André Bispo
066a352095 [PM-6702] Add Check your email screen used for cloud registration flow.
(cherry picked from commit fa8a5b80a2)
2024-06-22 15:24:45 +01:00
André Bispo
363998a624 [PM-8947] Add marketing toggle and rewrite terms and conditions UI
(cherry picked from commit 53430cdf8a)
2024-06-22 15:24:23 +01:00
André Bispo
9094f6f1d0 [PM-6701] Add Complete Registration screen
(cherry picked from commit da27115906)
2024-06-22 15:24:10 +01:00
André Bispo
6f7b6fea6d [PM-6701] Add start registration screen and all the navigation logic necessary in landing screen
(cherry picked from commit bc44043bfc)
2024-06-22 15:23:25 +01:00
André Bispo
6994ced5d2 [PM-6701] Extract environment selector to its own file for reusability
(cherry picked from commit a417e5cc11)
2024-06-22 15:23:00 +01:00
63 changed files with 4545 additions and 115 deletions

View File

@@ -55,6 +55,17 @@
<data android:mimeType="video/*" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="bitwarden.com" />
<data android:host="bitwarden.pw" />
<data android:host="bitwarden.eu" />
</intent-filter>
</activity>
<activity

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull
@@ -181,6 +182,7 @@ class MainViewModel @Inject constructor(
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
val hasVaultShortcut = intent.isMyVaultShortcut
val fido2CredentialRequestData = intent.getFido2CredentialRequestOrNull()
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
when {
passwordlessRequestData != null -> {
specialCircumstanceManager.specialCircumstance =
@@ -192,6 +194,14 @@ class MainViewModel @Inject constructor(
)
}
completeRegistrationData != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.CompleteRegistration(
completeRegistrationData = completeRegistrationData,
timestamp = System.currentTimeMillis()
)
}
autofillSaveItem != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.AutofillSave(

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
@@ -264,4 +265,14 @@ interface AuthDiskSource {
* Stores the [accountTokens] for the given [userId].
*/
fun storeAccountTokens(userId: String, accountTokens: AccountTokensJson?)
/**
* Gets the pre authentication urls for the given [userEmail].
*/
fun getEmailVerificationUrls(userEmail: String): EnvironmentUrlDataJson?
/**
* Stores the [urls] for the given [userEmail].
*/
fun storeEmailVerificationUrls(userEmail: String, urls: EnvironmentUrlDataJson)
}

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.disk
import android.content.SharedPreferences
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.platform.datasource.disk.BaseEncryptedDiskSource
@@ -40,6 +41,7 @@ private const val TWO_FACTOR_TOKEN_KEY = "twoFactorToken"
private const val MASTER_PASSWORD_HASH_KEY = "keyHash"
private const val POLICIES_KEY = "policies"
private const val SHOULD_TRUST_DEVICE_KEY = "shouldTrustDevice"
private const val EMAIL_VERIFICATION_URLS = "emailVerificationUrls"
/**
* Primary implementation of [AuthDiskSource].
@@ -376,6 +378,24 @@ class AuthDiskSourceImpl(
getMutableAccountTokensFlow(userId = userId).tryEmit(accountTokens)
}
override fun storeEmailVerificationUrls(
userEmail: String,
urls: EnvironmentUrlDataJson,
) {
putString(
key = EMAIL_VERIFICATION_URLS.appendIdentifier(userEmail),
value = json.encodeToString(urls),
)
}
override fun getEmailVerificationUrls(
userEmail: String,
): EnvironmentUrlDataJson? =
getString(key = EMAIL_VERIFICATION_URLS.appendIdentifier(userEmail))
?.let {
json.decodeFromStringOrNull(it)
}
private fun generateAndStoreUniqueAppId(): String =
UUID
.randomUUID()

View File

@@ -5,8 +5,11 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJso
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.Field
@@ -66,4 +69,10 @@ interface IdentityApi {
@POST("/accounts/register")
suspend fun register(@Body body: RegisterRequestJson): Result<RegisterResponseJson.Success>
@POST("/accounts/register/finish")
suspend fun registerFinish(@Body body: RegisterFinishRequestJson): Result<RegisterResponseJson.Success>
@POST("/accounts/register/send-verification-email")
suspend fun sendVerificationEmail(@Body body: SendVerificationEmailRequestJson): Result<ResponseBody?>
}

View File

@@ -0,0 +1,64 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequestJson.Keys
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for register.
*
* @param email the email to be registered.
* @param emailVerificationToken token used to finish the registration process.
* @param masterPasswordHash the master password (encrypted).
* @param masterPasswordHint the hint for the master password (nullable).
* @param captchaResponse the captcha bypass token.
* @param userSymmetricKey the user key for the request (encrypted).
* @param userAsymmetricKeys a [Keys] object containing public and private keys.
* @param kdfType the kdf type represented as an [Int].
* @param kdfIterations the number of kdf iterations.
*/
@Serializable
data class RegisterFinishRequestJson(
@SerialName("email")
val email: String,
@SerialName("emailVerificationToken")
val emailVerificationToken: String,
@SerialName("masterPasswordHash")
val masterPasswordHash: String,
@SerialName("masterPasswordHint")
val masterPasswordHint: String?,
@SerialName("captchaResponse")
val captchaResponse: String?,
@SerialName("userSymmetricKey")
val userSymmetricKey: String,
@SerialName("userAsymmetricKeys")
val userAsymmetricKeys: Keys,
@SerialName("kdf")
val kdfType: KdfTypeJson,
@SerialName("kdfIterations")
val kdfIterations: UInt,
) {
/**
* A keys object containing public and private keys.
*
* @param publicKey the public key (encrypted).
* @param encryptedPrivateKey the private key (encrypted).
*/
@Serializable
data class Keys(
@SerialName("publicKey")
val publicKey: String,
@SerialName("encryptedPrivateKey")
val encryptedPrivateKey: String,
)
}

View File

@@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for send verification email.
*
* @param email the email to be registered.
* @param name the name to be registered.
* @param receiveMarketingEmails the answer to receive marketing emails.
*/
@Serializable
data class SendVerificationEmailRequestJson(
@SerialName("email")
val email: String,
@SerialName("name")
val name: String?,
@SerialName("receiveMarketingEmails")
val receiveMarketingEmails: Boolean,
)

View File

@@ -0,0 +1,44 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed class SendVerificationEmailResponseJson {
/**
* Models a successful json response.
*
* @param emailVerificationToken the token to verify the email.
*/
@Serializable
data class Success(
val emailVerificationToken: String?,
) : SendVerificationEmailResponseJson()
/**
* Represents the json body of an invalid request.
*
* @param message
* @param validationErrors a map where each value is a list of error messages for each key.
* The values in the array should be used for display to the user, since the keys tend to come
* back as nonsense. (eg: empty string key)
*/
@Serializable
data class Invalid(
@SerialName("message")
val message: String?,
@SerialName("validationErrors")
val validationErrors: Map<String, List<String>>?,
) : SendVerificationEmailResponseJson()
/**
* A different error with a message.
*/
@Serializable
data class Error(
@SerialName("Message")
val message: String?,
) : SendVerificationEmailResponseJson()
}

View File

@@ -5,9 +5,12 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.IdentityTokenAuthM
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import okhttp3.ResponseBody
/**
* Provides an API for querying identity endpoints.
@@ -24,6 +27,11 @@ interface IdentityService {
*/
suspend fun register(body: RegisterRequestJson): Result<RegisterResponseJson>
/**
* Register a new account to Bitwarden using email verification flow.
*/
suspend fun registerFinish(body: RegisterFinishRequestJson): Result<RegisterResponseJson>
/**
* Make request to get an access token.
*
@@ -58,4 +66,9 @@ interface IdentityService {
* @param refreshToken The refresh token needed to obtain a new token.
*/
fun refreshTokenSynchronously(refreshToken: String): Result<RefreshTokenResponseJson>
/**
* Send a verification email.
*/
suspend fun sendVerificationEmail(body: SendVerificationEmailRequestJson): Result<ResponseBody?>
}

View File

@@ -7,8 +7,10 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJso
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
@@ -16,6 +18,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.util.executeForResul
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull
import com.x8bit.bitwarden.data.platform.util.DeviceModelProvider
import kotlinx.serialization.json.Json
import okhttp3.ResponseBody
class IdentityServiceImpl(
private val api: IdentityApi,
@@ -44,6 +47,21 @@ class IdentityServiceImpl(
) ?: throw throwable
}
@Suppress("MagicNumber")
override suspend fun registerFinish(body: RegisterFinishRequestJson): Result<RegisterResponseJson> =
api
.registerFinish(body)
.recoverCatching { throwable ->
val bitwardenError = throwable.toBitwardenError()
bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Invalid>(
codes = listOf(400, 429),
json = json,
) ?: bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Error>(
code = 429,
json = json,
) ?: throw throwable
}
@Suppress("MagicNumber")
override suspend fun getToken(
uniqueAppId: String,
@@ -101,4 +119,10 @@ class IdentityServiceImpl(
refreshToken = refreshToken,
)
.executeForResult()
override suspend fun sendVerificationEmail(
body: SendVerificationEmailRequestJson,
): Result<ResponseBody?> {
return api.sendVerificationEmail(body = body)
}
}

View File

@@ -19,6 +19,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
@@ -251,6 +252,7 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
email: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String? = null,
captchaToken: String?,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
@@ -347,4 +349,13 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
* policies for the current user.
*/
suspend fun validatePasswordAgainstPolicies(password: String): Boolean
/**
* Send a verification email.
*/
suspend fun sendVerificationEmail(
email: String,
name: String,
receiveMarketingEmails: Boolean,
): SendVerificationEmailResult
}

View File

@@ -15,10 +15,12 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJs
import com.x8bit.bitwarden.data.auth.datasource.network.model.IdentityTokenAuthModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
@@ -49,6 +51,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
@@ -717,6 +720,7 @@ class AuthRepositoryImpl(
email: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String?,
captchaToken: String?,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
@@ -745,21 +749,41 @@ class AuthRepositoryImpl(
kdf = kdf,
)
.flatMap { registerKeyResponse ->
identityService.register(
body = RegisterRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
captchaResponse = captchaToken,
key = registerKeyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
if (emailVerificationToken == null) {
identityService.register(
body = RegisterRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
captchaResponse = captchaToken,
key = registerKeyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
)
)
} else {
identityService.registerFinish(
body = RegisterFinishRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
captchaResponse = captchaToken,
userSymmetricKey = registerKeyResponse.encryptedUserKey,
userAsymmetricKeys = RegisterFinishRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
)
}
}
.fold(
onSuccess = {
@@ -1108,6 +1132,30 @@ class AuthRepositoryImpl(
): Boolean = passwordPolicies
.all { validatePasswordAgainstPolicy(password, it) }
override suspend fun sendVerificationEmail(
email: String,
name: String,
receiveMarketingEmails: Boolean,
): SendVerificationEmailResult =
identityService.sendVerificationEmail(
SendVerificationEmailRequestJson(
email = email,
name = name,
receiveMarketingEmails = receiveMarketingEmails,
)
).fold(
onSuccess = {
SendVerificationEmailResult.Success(it?.string())
},
onFailure = {
// error throw in [ResultCall] if response body is null
if (it is IllegalStateException) {
return SendVerificationEmailResult.Success(null)
}
return SendVerificationEmailResult.Error(null)
}
)
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,

View File

@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of sending a verification email.
*/
sealed class SendVerificationEmailResult {
/**
* Email sent succeeded.
*
* @param emailVerificationToken the token to verify the email.
*/
data class Success(
val emailVerificationToken: String? = null,
) : SendVerificationEmailResult()
/**
* There was an error sending the email.
*
* @param errorMessage a message describing the error.
*/
data class Error(val errorMessage: String?) : SendVerificationEmailResult()
}

View File

@@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.auth.util
import android.content.Intent
import android.net.Uri
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
/**
* Checks if the given [Intent] contains data to complete registration.
* The [CompleteRegistrationData] will be returned when present.
*/
fun Intent.getCompleteRegistrationDataIntentOrNull(): CompleteRegistrationData? {
val sanitizedUriString = data.toString().replace("/#/", "/")
val uri = runCatching { Uri.parse(sanitizedUriString) }.getOrNull() ?: return null
uri.host ?: return null
if (uri.path != "/finish-signup") return null
val email = uri.getQueryParameter("email") ?: return null
val verificationToken = uri.getQueryParameter("token") ?: return null
val fromEmail = uri.getBooleanQueryParameter("fromEmail", true)
return CompleteRegistrationData(
email = email,
verificationToken = verificationToken,
fromEmail = fromEmail
)
}

View File

@@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.platform.manager.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Required data to complete ongoing registration process.
*
* @property email The email of the user creating the account.
* @property verificationToken The token required to finish the registration process.
* @property fromEmail indicates that this information came from an email AppLink.
*/
@Parcelize
data class CompleteRegistrationData(
val email: String,
val verificationToken: String,
val fromEmail: Boolean,
) : Parcelable

View File

@@ -48,6 +48,15 @@ sealed class SpecialCircumstance : Parcelable {
val shouldFinishWhenComplete: Boolean,
) : SpecialCircumstance()
/**
* The app was launched via AppLink in order to allow the user complete an ongoing registration.
*/
@Parcelize
data class CompleteRegistration(
val completeRegistrationData: CompleteRegistrationData,
val timestamp: Long,
) : SpecialCircumstance()
/**
* The app was launched via the credential manager framework in order to allow the user to
* manually save a passkey to their vault.

View File

@@ -17,6 +17,7 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
SpecialCircumstance.GeneratorShortcut -> null
SpecialCircumstance.VaultShortcut -> null
is SpecialCircumstance.Fido2Save -> null
is SpecialCircumstance.CompleteRegistration -> null
}
/**
@@ -31,6 +32,7 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
SpecialCircumstance.GeneratorShortcut -> null
SpecialCircumstance.VaultShortcut -> null
is SpecialCircumstance.Fido2Save -> null
is SpecialCircumstance.CompleteRegistration -> null
}
/**

View File

@@ -16,4 +16,15 @@ interface EnvironmentRepository {
* Emits updates that track [environment].
*/
val environmentStateFlow: StateFlow<Environment>
/**
* Stores the current environment for the given [userEmail].
*/
fun saveCurrentEnvironmentForEmail(userEmail: String)
/**
* Loads the environment for the given [userEmail].
* returns boolean indicates if the load was successful
*/
fun loadEnvironmentForEmail(userEmail: String): Boolean
}

View File

@@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrls
import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrlsOrDefault
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
@@ -18,7 +19,7 @@ import kotlinx.coroutines.flow.stateIn
*/
class EnvironmentRepositoryImpl(
private val environmentDiskSource: EnvironmentDiskSource,
authDiskSource: AuthDiskSource,
private val authDiskSource: AuthDiskSource,
dispatcherManager: DispatcherManager,
) : EnvironmentRepository {
@@ -55,4 +56,13 @@ class EnvironmentRepositoryImpl(
}
.launchIn(scope)
}
override fun loadEnvironmentForEmail(userEmail: String): Boolean {
val urls = authDiskSource.getEmailVerificationUrls(userEmail) ?: return false
environment = urls.toEnvironmentUrls()
return true
}
override fun saveCurrentEnvironmentForEmail(userEmail: String) =
authDiskSource.storeEmailVerificationUrls(userEmail, environment.environmentUrlData)
}

View File

@@ -6,6 +6,10 @@ import androidx.navigation.NavHostController
import androidx.navigation.NavOptions
import androidx.navigation.navOptions
import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.auth.feature.checkemail.checkEmailDestination
import com.x8bit.bitwarden.ui.auth.feature.checkemail.navigateToCheckEmail
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.completeRegistrationDestination
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
import com.x8bit.bitwarden.ui.auth.feature.createaccount.createAccountDestination
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.enterpriseSignOnDestination
@@ -23,6 +27,8 @@ import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHint
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint
import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword
import com.x8bit.bitwarden.ui.auth.feature.setpassword.setPasswordDestination
import com.x8bit.bitwarden.ui.auth.feature.startregistration.navigateToStartRegistration
import com.x8bit.bitwarden.ui.auth.feature.startregistration.startRegistrationDestination
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.navigateToTwoFactorLogin
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination
@@ -49,6 +55,31 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
)
},
)
startRegistrationDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToCompleteRegistration = { emailAddress, verificationToken ->
navController.navigateToCompleteRegistration(
emailAddress = emailAddress,
verificationToken = verificationToken,
fromEmail = false
)
},
onNavigateToCheckEmail = { emailAddress ->
navController.navigateToCheckEmail(emailAddress)
},
onNavigateToEnvironment = { navController.navigateToEnvironment() }
)
checkEmailDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateBackToLanding = {
navController.popBackStack(route = LANDING_ROUTE, inclusive = false)
})
completeRegistrationDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToLanding = {
navController.popBackStack(route = LANDING_ROUTE, inclusive = false)
},
)
enterpriseSignOnDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToSetPassword = { navController.navigateToSetPassword() },
@@ -71,6 +102,7 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
onNavigateToEnvironment = {
navController.navigateToEnvironment()
},
onNavigateToStartRegistration = { navController.navigateToStartRegistration() }
)
loginDestination(
onNavigateBack = { navController.popBackStack() },

View File

@@ -0,0 +1,52 @@
package com.x8bit.bitwarden.ui.auth.feature.checkemail
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val EMAIL: String = "email"
private const val CHECK_EMAIL_ROUTE: String = "check_email/{$EMAIL}"
/**
* Navigate to the check email screen.
*/
fun NavController.navigateToCheckEmail(emailAddress: String, navOptions: NavOptions? = null) {
this.navigate("check_email/$emailAddress", navOptions)
}
/**
* Class to retrieve check email arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class CheckEmailArgs(
val emailAddress: String,
) {
constructor(savedStateHandle: SavedStateHandle) : this(
emailAddress = checkNotNull(savedStateHandle.get<String>(EMAIL)),
)
}
/**
* Add the check email screen to the nav graph.
*/
fun NavGraphBuilder.checkEmailDestination(
onNavigateBack: () -> Unit,
onNavigateBackToLanding: () -> Unit,
) {
composableWithSlideTransitions(
route = CHECK_EMAIL_ROUTE,
arguments = listOf(
navArgument(EMAIL) { type = NavType.StringType },
)
) {
CheckEmailScreen(
onNavigateBack = onNavigateBack,
onNavigateBackToLanding = onNavigateBackToLanding
)
}
}

View File

@@ -0,0 +1,231 @@
package com.x8bit.bitwarden.ui.auth.feature.checkemail
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
/**
* Top level composable for the check email screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun CheckEmailScreen(
onNavigateBack: () -> Unit,
onNavigateBackToLanding: () -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: CheckEmailViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel) { event ->
when (event) {
is CheckEmailEvent.NavigateBack -> {
onNavigateBack.invoke()
}
is CheckEmailEvent.NavigateToEmailApp -> {
intentManager.openEmailApp()
}
is CheckEmailEvent.NavigateBackToLanding -> {
onNavigateBackToLanding.invoke()
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.create_account),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(CheckEmailAction.CloseTap) }
}
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.imePadding()
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(32.dp))
Image(
painter = rememberVectorPainter(id = R.drawable.email_check),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = Modifier
.padding(horizontal = 16.dp)
.height(112.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = stringResource(id = R.string.check_your_email),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 24.dp)
.wrapContentHeight()
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
val descriptionAnnotatedString = CreateAnnotatedString(
mainText = stringResource(
id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account,
state.email
),
highlightText = state.email,
highlightSpanStyle = SpanStyle(
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold
)
)
Text(
text = descriptionAnnotatedString,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = 24.dp)
.fillMaxWidth()
.wrapContentHeight(),
)
Spacer(modifier = Modifier.height(32.dp))
BitwardenFilledButton(
label = stringResource(id = R.string.open_email_app),
onClick = remember(viewModel) {
{ viewModel.trySendAction(CheckEmailAction.OpenEmailTap) }
},
modifier = Modifier
.testTag("OpenEmailApp")
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(32.dp))
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
val goBackAnnotatedString = CreateAnnotatedString(
mainText = stringResource(id = R.string.no_email_go_back_to_edit_your_email_address),
highlightText = stringResource(id = R.string.go_back)
)
ClickableText(
text = goBackAnnotatedString,
onClick = {
goBackAnnotatedString
.getStringAnnotations("URL", it, it)
.firstOrNull()?.let {
viewModel.trySendAction(CheckEmailAction.CloseTap)
}
}
)
Spacer(modifier = Modifier.height(32.dp))
val logInAnnotatedString = CreateAnnotatedString(
mainText = stringResource(id = R.string.or_log_in_you_may_already_have_an_account),
highlightText = stringResource(id = R.string.log_in)
)
ClickableText(
text = logInAnnotatedString,
onClick = {
logInAnnotatedString
.getStringAnnotations("URL", it, it)
.firstOrNull()?.let {
viewModel.trySendAction(CheckEmailAction.LoginTap)
}
}
)
}
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Composable
private fun CreateAnnotatedString(
mainText: String,
highlightText: String,
mainSpanStyle: SpanStyle = SpanStyle(
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyMedium.fontSize
),
highlightSpanStyle: SpanStyle = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold
),
): AnnotatedString {
return buildAnnotatedString {
val startIndex = mainText.indexOf(highlightText, ignoreCase = true)
val endIndex = startIndex + highlightText.length
append(mainText)
addStyle(
style = mainSpanStyle,
start = 0,
end = mainText.length
)
addStyle(
style = highlightSpanStyle,
start = startIndex,
end = endIndex
)
addStringAnnotation(
tag = "URL",
annotation = highlightText,
start = startIndex,
end = endIndex
)
}
}

View File

@@ -0,0 +1,90 @@
package com.x8bit.bitwarden.ui.auth.feature.checkemail
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Models logic for the check email screen.
*/
@HiltViewModel
class CheckEmailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<CheckEmailState, CheckEmailEvent, CheckEmailAction>(
initialState = savedStateHandle[KEY_STATE]
?: CheckEmailState(
email = CheckEmailArgs(savedStateHandle).emailAddress
),
) {
init {
// As state updates, write to saved state handle:
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: CheckEmailAction) {
when (action) {
CheckEmailAction.CloseTap -> sendEvent(CheckEmailEvent.NavigateBack)
CheckEmailAction.LoginTap -> sendEvent(CheckEmailEvent.NavigateBackToLanding)
CheckEmailAction.OpenEmailTap -> sendEvent(CheckEmailEvent.NavigateToEmailApp)
}
}
}
/**
* UI state for the check email screen.
*/
@Parcelize
data class CheckEmailState(
val email: String,
) : Parcelable
/**
* Models events for the check email screen.
*/
sealed class CheckEmailEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : CheckEmailEvent()
/**
* Navigate to email app.
*/
data object NavigateToEmailApp : CheckEmailEvent()
/**
* Navigate to landing screen.
*/
data object NavigateBackToLanding : CheckEmailEvent()
}
/**
* Models actions for the check email screen.
*/
sealed class CheckEmailAction {
/**
* User tapped close.
*/
data object CloseTap : CheckEmailAction()
/**
* User tapped log in.
*/
data object LoginTap : CheckEmailAction()
/**
* User tapped open email.
*/
data object OpenEmailTap : CheckEmailAction()
}

View File

@@ -0,0 +1,70 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val EMAIL_ADDRESS: String = "email_address"
private const val VERIFICATION_TOKEN: String = "verification_token"
private const val FROM_EMAIL: String = "from_email"
private const val COMPLETE_REGISTRATION_PREFIX = "complete_registration"
private const val COMPLETE_REGISTRATION_ROUTE =
"$COMPLETE_REGISTRATION_PREFIX/{$EMAIL_ADDRESS}/{$VERIFICATION_TOKEN}/{$FROM_EMAIL}"
/**
* Class to retrieve complete registration arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class CompleteRegistrationArgs(
val emailAddress: String,
val verificationToken: String,
val fromEmail: Boolean,
) {
constructor(savedStateHandle: SavedStateHandle) : this(
emailAddress = checkNotNull(savedStateHandle.get<String>(EMAIL_ADDRESS)),
verificationToken = checkNotNull(savedStateHandle.get<String>(VERIFICATION_TOKEN)),
fromEmail = checkNotNull(savedStateHandle.get<Boolean>(FROM_EMAIL))
)
}
/**
* Navigate to the complete registration screen.
*/
fun NavController.navigateToCompleteRegistration(
emailAddress: String,
verificationToken: String,
fromEmail: Boolean,
navOptions: NavOptions? = null,
) {
this.navigate(
"$COMPLETE_REGISTRATION_PREFIX/$emailAddress/$verificationToken/$fromEmail",
navOptions
)
}
/**
* Add the complete registration screen to the nav graph.
*/
fun NavGraphBuilder.completeRegistrationDestination(
onNavigateBack: () -> Unit,
onNavigateToLanding: () -> Unit,
) {
composableWithSlideTransitions(
route = COMPLETE_REGISTRATION_ROUTE,
arguments = listOf(
navArgument(EMAIL_ADDRESS) { type = NavType.StringType },
navArgument(VERIFICATION_TOKEN) { type = NavType.StringType },
navArgument(FROM_EMAIL) { type = NavType.BoolType },
),
) {
CompleteRegistrationScreen(
onNavigateBack = onNavigateBack,
onNavigateToLanding = onNavigateToLanding
)
}
}

View File

@@ -0,0 +1,237 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ErrorDialogDismiss
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
/**
* Top level composable for the complete registration screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun CompleteRegistrationScreen(
onNavigateBack: () -> Unit,
onNavigateToLanding: () -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: CompleteRegistrationViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
EventsEffect(viewModel) { event ->
when (event) {
is CompleteRegistrationEvent.NavigateBack -> onNavigateBack.invoke()
is CompleteRegistrationEvent.ShowToast -> {
Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show()
}
is CompleteRegistrationEvent.NavigateToCaptcha -> {
intentManager.startCustomTabsActivity(uri = event.uri)
}
is CompleteRegistrationEvent.NavigateToLanding -> {
onNavigateToLanding()
}
}
}
// Show dialog if needed:
when (val dialog = state.dialog) {
is CompleteRegistrationDialog.Error -> {
BitwardenBasicDialog(
visibilityState = dialog.state,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
)
}
is CompleteRegistrationDialog.HaveIBeenPwned -> {
BitwardenTwoButtonDialog(
title = dialog.title(),
message = dialog.message(),
confirmButtonText = stringResource(id = R.string.yes),
dismissButtonText = stringResource(id = R.string.no),
onConfirmClick = remember(viewModel) {
{ viewModel.trySendAction(ContinueWithBreachedPasswordClick) }
},
onDismissClick = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
)
}
CompleteRegistrationDialog.Loading -> {
BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(R.string.create_account.asText()),
)
}
null -> Unit
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.set_password),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(CloseClick) }
},
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.create_account),
onClick = remember(viewModel) {
{ viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) }
},
modifier = Modifier.testTag("CreateAccountButton"),
)
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.imePadding()
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(
id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account,
state.userEmail
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
var showPassword by rememberSaveable { mutableStateOf(false) }
BitwardenPasswordField(
label = stringResource(id = R.string.master_password),
showPassword = showPassword,
showPasswordChange = { showPassword = it },
value = state.passwordInput,
hint = state.passwordLengthLabel(),
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(PasswordInputChange(it)) }
},
modifier = Modifier
.testTag("MasterPasswordEntry")
.fillMaxWidth()
.padding(horizontal = 16.dp),
showPasswordTestTag = "PasswordVisibilityToggle",
)
Spacer(modifier = Modifier.height(8.dp))
PasswordStrengthIndicator(
modifier = Modifier.padding(horizontal = 16.dp),
state = state.passwordStrengthState,
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.retype_master_password),
value = state.confirmPasswordInput,
showPassword = showPassword,
showPasswordChange = { showPassword = it },
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(ConfirmPasswordInputChange(it)) }
},
modifier = Modifier
.testTag("ConfirmMasterPasswordEntry")
.fillMaxWidth()
.padding(horizontal = 16.dp),
showPasswordTestTag = "ConfirmPasswordVisibilityToggle",
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(id = R.string.master_password_hint),
value = state.passwordHintInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(PasswordHintChange(it)) }
},
hint = stringResource(id = R.string.master_password_hint_description),
modifier = Modifier
.testTag("MasterPasswordHintLabel")
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenSwitch(
label = stringResource(id = R.string.check_known_data_breaches_for_this_password),
isChecked = state.isCheckDataBreachesToggled,
onCheckedChange = remember(viewModel) {
{ newState ->
viewModel.trySendAction(CheckDataBreachesToggle(newState = newState))
}
},
modifier = Modifier
.testTag("CheckExposedMasterPasswordToggle")
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}

View File

@@ -0,0 +1,545 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import android.net.Uri
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal.ReceivePasswordStrengthResult
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MIN_PASSWORD_LENGTH = 12
/**
* Models logic for the create account screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class CompleteRegistrationViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
private val specialCircumstance: SpecialCircumstanceManager,
) : BaseViewModel<CompleteRegistrationState, CompleteRegistrationEvent, CompleteRegistrationAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
val args = CompleteRegistrationArgs(savedStateHandle)
CompleteRegistrationState(
userEmail = args.emailAddress,
emailVerificationToken = args.verificationToken,
fromEmail = args.fromEmail,
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isCheckDataBreachesToggled = true,
dialog = null,
passwordStrengthState = PasswordStrengthState.NONE,
)
},
) {
/**
* Keeps track of async request to get password strength. Should be cancelled
* when user input changes.
*/
private var passwordStrengthJob: Job = Job().apply { complete() }
init {
verifyEmailAddress()
// As state updates, write to saved state handle:
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
authRepository
.captchaTokenResultFlow
.onEach {
sendAction(
CompleteRegistrationAction.Internal.ReceiveCaptchaToken(
tokenResult = it,
),
)
}
.launchIn(viewModelScope)
}
private fun verifyEmailAddress() {
if (!state.fromEmail)
return
viewModelScope.launch {
sendEvent(CompleteRegistrationEvent.ShowToast(message = R.string.email_verified.asText()))
}
}
public override fun onCleared() {
// clean the specialCircumstance after being handled
specialCircumstance.specialCircumstance = null
super.onCleared()
}
override fun handleAction(action: CompleteRegistrationAction) {
when (action) {
is CompleteRegistrationAction.CreateAccountClick -> handleCreateAccountClick()
is ConfirmPasswordInputChange -> handleConfirmPasswordInputChanged(action)
is PasswordHintChange -> handlePasswordHintChanged(action)
is PasswordInputChange -> handlePasswordInputChanged(action)
is CompleteRegistrationAction.CloseClick -> handleCloseClick()
is CompleteRegistrationAction.ErrorDialogDismiss -> handleDialogDismiss()
is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action)
is CompleteRegistrationAction.Internal.ReceiveRegisterResult -> {
handleReceiveRegisterAccountResult(action)
}
is CompleteRegistrationAction.Internal.ReceiveCaptchaToken -> {
handleReceiveCaptchaToken(action)
}
ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick()
is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action)
}
}
private fun handlePasswordStrengthResult(action: ReceivePasswordStrengthResult) {
when (val result = action.result) {
is PasswordStrengthResult.Success -> {
val updatedState = when (result.passwordStrength) {
PasswordStrength.LEVEL_0 -> PasswordStrengthState.WEAK_1
PasswordStrength.LEVEL_1 -> PasswordStrengthState.WEAK_2
PasswordStrength.LEVEL_2 -> PasswordStrengthState.WEAK_3
PasswordStrength.LEVEL_3 -> PasswordStrengthState.GOOD
PasswordStrength.LEVEL_4 -> PasswordStrengthState.STRONG
}
mutableStateFlow.update { oldState ->
oldState.copy(
passwordStrengthState = updatedState,
)
}
}
PasswordStrengthResult.Error -> {
// Leave UI the same
}
}
}
private fun handleReceiveCaptchaToken(
action: CompleteRegistrationAction.Internal.ReceiveCaptchaToken,
) {
when (val result = action.tokenResult) {
is CaptchaCallbackTokenResult.MissingToken -> {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.captcha_failed.asText(),
),
),
)
}
}
is CaptchaCallbackTokenResult.Success -> {
submitRegisterAccountRequest(
shouldCheckForDataBreaches = false,
shouldIgnorePasswordStrength = true,
captchaToken = result.token,
)
}
}
}
@Suppress("LongMethod", "MaxLineLength")
private fun handleReceiveRegisterAccountResult(
action: CompleteRegistrationAction.Internal.ReceiveRegisterResult,
) {
when (val registerAccountResult = action.registerResult) {
is RegisterResult.CaptchaRequired -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(
CompleteRegistrationEvent.NavigateToCaptcha(
uri = generateUriForCaptcha(captchaId = registerAccountResult.captchaId),
),
)
}
is RegisterResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = registerAccountResult.errorMessage?.asText()
?: R.string.generic_error_message.asText(),
),
),
)
}
}
is RegisterResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(
CompleteRegistrationEvent.NavigateToLanding,
)
}
RegisterResult.DataBreachFound -> {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.HaveIBeenPwned(
title = R.string.exposed_master_password.asText(),
message = R.string.password_found_in_a_data_breach_alert_description.asText(),
),
)
}
}
RegisterResult.DataBreachAndWeakPassword -> {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.HaveIBeenPwned(
title = R.string.weak_and_exposed_master_password.asText(),
message = R.string.weak_password_identified_and_found_in_a_data_breach_alert_description.asText(),
),
)
}
}
RegisterResult.WeakPassword -> {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.HaveIBeenPwned(
title = R.string.weak_master_password.asText(),
message = R.string.weak_password_identified_use_a_strong_password_to_protect_your_account.asText(),
),
)
}
}
}
}
private fun handleCheckDataBreachesToggle(action: CheckDataBreachesToggle) {
mutableStateFlow.update {
it.copy(isCheckDataBreachesToggled = action.newState)
}
}
private fun handleDialogDismiss() {
mutableStateFlow.update {
it.copy(dialog = null)
}
}
private fun handleCloseClick() {
sendEvent(CompleteRegistrationEvent.NavigateBack)
}
private fun handlePasswordHintChanged(action: PasswordHintChange) {
mutableStateFlow.update { it.copy(passwordHintInput = action.input) }
}
private fun handlePasswordInputChanged(action: PasswordInputChange) {
// Update input:
mutableStateFlow.update { it.copy(passwordInput = action.input) }
// Update password strength:
passwordStrengthJob.cancel()
if (action.input.isEmpty()) {
mutableStateFlow.update {
it.copy(passwordStrengthState = PasswordStrengthState.NONE)
}
} else {
passwordStrengthJob = viewModelScope.launch {
val result = authRepository.getPasswordStrength(
email = state.userEmail,
password = action.input,
)
trySendAction(ReceivePasswordStrengthResult(result))
}
}
}
private fun handleConfirmPasswordInputChanged(action: ConfirmPasswordInputChange) {
mutableStateFlow.update { it.copy(confirmPasswordInput = action.input) }
}
private fun handleCreateAccountClick() = when {
state.userEmail.isBlank() -> {
val dialog = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.email_address.asText()),
)
mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
}
!state.userEmail.isValidEmail() -> {
val dialog = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_email.asText(),
)
mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
}
state.passwordInput.length < MIN_PASSWORD_LENGTH -> {
val dialog = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(MIN_PASSWORD_LENGTH),
)
mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
}
state.passwordInput != state.confirmPasswordInput -> {
val dialog = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
)
mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
}
else -> {
submitRegisterAccountRequest(
shouldCheckForDataBreaches = state.isCheckDataBreachesToggled,
shouldIgnorePasswordStrength = false,
captchaToken = null,
)
}
}
private fun handleContinueWithBreachedPasswordClick() {
submitRegisterAccountRequest(
shouldCheckForDataBreaches = false,
shouldIgnorePasswordStrength = true,
captchaToken = null,
)
}
private fun submitRegisterAccountRequest(
shouldCheckForDataBreaches: Boolean,
shouldIgnorePasswordStrength: Boolean,
captchaToken: String?,
) {
mutableStateFlow.update {
it.copy(dialog = CompleteRegistrationDialog.Loading)
}
viewModelScope.launch {
// Update region accordingly to a user email
environmentRepository.loadEnvironmentForEmail(state.userEmail)
val result = authRepository.register(
shouldCheckDataBreaches = shouldCheckForDataBreaches,
isMasterPasswordStrong = shouldIgnorePasswordStrength ||
state.isMasterPasswordStrong,
emailVerificationToken = state.emailVerificationToken,
email = state.userEmail,
masterPassword = state.passwordInput,
masterPasswordHint = state.passwordHintInput.ifBlank { null },
captchaToken = captchaToken,
)
sendAction(
CompleteRegistrationAction.Internal.ReceiveRegisterResult(
registerResult = result,
),
)
}
}
}
/**
* UI state for the complete registration screen.
*/
@Parcelize
data class CompleteRegistrationState(
val userEmail: String,
val emailVerificationToken: String,
val fromEmail: Boolean,
val passwordInput: String,
val confirmPasswordInput: String,
val passwordHintInput: String,
val isCheckDataBreachesToggled: Boolean,
val dialog: CompleteRegistrationDialog?,
val passwordStrengthState: PasswordStrengthState,
) : Parcelable {
val passwordLengthLabel: Text
// Have to concat a few strings here, resulting string is:
// Important: Your master password cannot be recovered if you forget it! 12
// characters minimum
@Suppress("MaxLineLength")
get() = R.string.important.asText()
.concat(
": ".asText(),
R.string.your_master_password_cannot_be_recovered_if_you_forget_it_x_characters_minimum
.asText(MIN_PASSWORD_LENGTH),
)
/**
* Whether or not the provided master password is considered strong.
*/
val isMasterPasswordStrong: Boolean
get() = when (passwordStrengthState) {
PasswordStrengthState.NONE,
PasswordStrengthState.WEAK_1,
PasswordStrengthState.WEAK_2,
PasswordStrengthState.WEAK_3,
-> false
PasswordStrengthState.GOOD,
PasswordStrengthState.STRONG,
-> true
}
}
/**
* Models dialogs that can be displayed on the complete registration screen.
*/
sealed class CompleteRegistrationDialog : Parcelable {
/**
* Loading dialog.
*/
@Parcelize
data object Loading : CompleteRegistrationDialog()
/**
* Confirm the user wants to continue with potentially breached password.
*
* @param title The title for the HaveIBeenPwned dialog.
* @param message The message for the HaveIBeenPwned dialog.
*/
@Parcelize
data class HaveIBeenPwned(
val title: Text,
val message: Text,
) : CompleteRegistrationDialog()
/**
* General error dialog with an OK button.
*/
@Parcelize
data class Error(val state: BasicDialogState.Shown) : CompleteRegistrationDialog()
}
/**
* Models events for the complete registration screen.
*/
sealed class CompleteRegistrationEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : CompleteRegistrationEvent()
/**
* Show a toast with the given message.
*/
data class ShowToast(
val message: Text,
) : CompleteRegistrationEvent()
/**
* Navigates to the captcha verification screen.
*/
data class NavigateToCaptcha(val uri: Uri) : CompleteRegistrationEvent()
/**
* Navigates to the landing screen.
*/
data object NavigateToLanding : CompleteRegistrationEvent()
}
/**
* Models actions for the complete registration screen.
*/
sealed class CompleteRegistrationAction {
/**
* User clicked create account.
*/
data object CreateAccountClick : CompleteRegistrationAction()
/**
* User clicked close.
*/
data object CloseClick : CompleteRegistrationAction()
/**
* User clicked "Yes" when being asked if they are sure they want to use a breached password.
*/
data object ContinueWithBreachedPasswordClick : CompleteRegistrationAction()
/**
* Password input changed.
*/
data class PasswordInputChange(val input: String) : CompleteRegistrationAction()
/**
* Confirm password input changed.
*/
data class ConfirmPasswordInputChange(val input: String) : CompleteRegistrationAction()
/**
* Password hint input changed.
*/
data class PasswordHintChange(val input: String) : CompleteRegistrationAction()
/**
* User dismissed the error dialog.
*/
data object ErrorDialogDismiss : CompleteRegistrationAction()
/**
* User tapped check data breaches toggle.
*/
data class CheckDataBreachesToggle(val newState: Boolean) : CompleteRegistrationAction()
/**
* Models actions that the [CompleteRegistrationViewModel] itself might send.
*/
sealed class Internal : CompleteRegistrationAction() {
/**
* Indicates a captcha callback token has been received.
*/
data class ReceiveCaptchaToken(
val tokenResult: CaptchaCallbackTokenResult,
) : Internal()
/**
* Indicates a [RegisterResult] has been received.
*/
data class ReceiveRegisterResult(
val registerResult: RegisterResult,
) : Internal()
/**
* Indicates a password strength result has been received.
*/
data class ReceivePasswordStrengthResult(
val result: PasswordStrengthResult,
) : Internal()
}
}

View File

@@ -1,4 +1,4 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState

View File

@@ -49,6 +49,7 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthIndicator
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick

View File

@@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange

View File

@@ -21,6 +21,7 @@ fun NavGraphBuilder.landingDestination(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (emailAddress: String) -> Unit,
onNavigateToEnvironment: () -> Unit,
onNavigateToStartRegistration: () -> Unit,
) {
composableWithStayTransitions(
route = LANDING_ROUTE,
@@ -29,6 +30,7 @@ fun NavGraphBuilder.landingDestination(
onNavigateToCreateAccount = onNavigateToCreateAccount,
onNavigateToLogin = onNavigateToLogin,
onNavigateToEnvironment = onNavigateToEnvironment,
onNavigateToStartRegistration = onNavigateToStartRegistration
)
}
}

View File

@@ -1,10 +1,7 @@
package com.x8bit.bitwarden.ui.auth.feature.landing
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -17,11 +14,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
@@ -34,7 +28,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
@@ -55,14 +48,12 @@ import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenSelectionRow
import com.x8bit.bitwarden.ui.platform.components.dropdown.EnvironmentSelector
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.util.displayLabel
import kotlinx.collections.immutable.toImmutableList
/**
@@ -75,6 +66,7 @@ fun LandingScreen(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (emailAddress: String) -> Unit,
onNavigateToEnvironment: () -> Unit,
onNavigateToStartRegistration: () -> Unit,
viewModel: LandingViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@@ -86,6 +78,7 @@ fun LandingScreen(
)
LandingEvent.NavigateToEnvironment -> onNavigateToEnvironment()
LandingEvent.NavigateToStartRegistration -> onNavigateToStartRegistration()
}
}
@@ -268,6 +261,7 @@ private fun LandingScreenContent(
Spacer(modifier = Modifier.height(2.dp))
EnvironmentSelector(
labelText = stringResource(id = R.string.logging_in_on),
selectedOption = state.selectedEnvironmentType,
onOptionSelected = onEnvironmentTypeSelect,
modifier = Modifier
@@ -326,82 +320,3 @@ private fun LandingScreenContent(
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
/**
* A dropdown selector UI component specific to region url selection on the Landing screen.
*
* This composable displays a dropdown menu allowing users to select a region
* from a list of options. When an option is selected, it invokes the provided callback
* and displays the currently selected region on the UI.
*
* @param selectedOption The currently selected environment option.
* @param onOptionSelected A callback that gets invoked when an environment option is selected
* and passes the selected option as an argument.
* @param modifier A [Modifier] for the composable.
*
*/
@Composable
private fun EnvironmentSelector(
selectedOption: Environment.Type,
onOptionSelected: (Environment.Type) -> Unit,
modifier: Modifier = Modifier,
) {
val options = Environment.Type.entries.toTypedArray()
var shouldShowDialog by rememberSaveable { mutableStateOf(false) }
Box(modifier = modifier) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.clickable(
indication = rememberRipple(
bounded = true,
color = MaterialTheme.colorScheme.primary,
),
interactionSource = remember { MutableInteractionSource() },
onClick = { shouldShowDialog = !shouldShowDialog },
)
.padding(
vertical = 8.dp,
horizontal = 16.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(id = R.string.logging_in_on),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 12.dp),
)
Text(
text = selectedOption.displayLabel(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp),
)
Icon(
painter = rememberVectorPainter(id = R.drawable.ic_region_select_dropdown),
contentDescription = stringResource(id = R.string.region),
tint = MaterialTheme.colorScheme.primary,
)
}
if (shouldShowDialog) {
BitwardenSelectionDialog(
title = stringResource(id = R.string.logging_in_on),
onDismissRequest = { shouldShowDialog = false },
) {
options.forEach {
BitwardenSelectionRow(
text = it.displayLabel,
onClick = {
onOptionSelected.invoke(it)
shouldShowDialog = false
},
isSelected = it == selectedOption,
)
}
}
}
}
}

View File

@@ -186,7 +186,11 @@ class LandingViewModel @Inject constructor(
}
private fun handleCreateAccountClicked() {
sendEvent(LandingEvent.NavigateToCreateAccount)
// TODO PM-9401: ADD FEATURE FLAG email-verification
if (true)
sendEvent(LandingEvent.NavigateToStartRegistration)
else
sendEvent(LandingEvent.NavigateToCreateAccount)
}
private fun handleDialogDismiss() {
@@ -284,6 +288,11 @@ sealed class LandingEvent {
*/
data object NavigateToCreateAccount : LandingEvent()
/**
* Navigates to the Start Registration screen.
*/
data object NavigateToStartRegistration : LandingEvent()
/**
* Navigates to the Login screen with the given email address and region label.
*/

View File

@@ -0,0 +1,39 @@
package com.x8bit.bitwarden.ui.auth.feature.startregistration
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val START_REGISTRATION_ROUTE = "start_registration"
/**
* Navigate to the start registration screen.
*/
fun NavController.navigateToStartRegistration(navOptions: NavOptions? = null) {
this.navigate(START_REGISTRATION_ROUTE, navOptions)
}
/**
* Add the start registration screen to the nav graph.
*/
fun NavGraphBuilder.startRegistrationDestination(
onNavigateBack: () -> Unit,
onNavigateToCompleteRegistration: (
emailAddress: String,
verificationToken: String,
) -> Unit,
onNavigateToCheckEmail: (email: String) -> Unit,
onNavigateToEnvironment: () -> Unit,
) {
composableWithSlideTransitions(
route = START_REGISTRATION_ROUTE,
) {
StartRegistrationScreen(
onNavigateBack = onNavigateBack,
onNavigateToCompleteRegistration = onNavigateToCompleteRegistration,
onNavigateToCheckEmail = onNavigateToCheckEmail,
onNavigateToEnvironment = onNavigateToEnvironment,
)
}
}

View File

@@ -0,0 +1,417 @@
package com.x8bit.bitwarden.ui.auth.feature.startregistration
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ErrorDialogDismiss
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.NameInputChange
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.PrivacyPolicyClick
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.TermsClick
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateToPrivacyPolicy
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateToTerms
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.dropdown.EnvironmentSelector
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
/**
* Top level composable for the start registration screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun StartRegistrationScreen(
onNavigateBack: () -> Unit,
onNavigateToCompleteRegistration: (
emailAddress: String,
verificationToken: String,
) -> Unit,
onNavigateToCheckEmail: (email: String) -> Unit,
onNavigateToEnvironment: () -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: StartRegistrationViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
EventsEffect(viewModel) { event ->
when (event) {
is NavigateToPrivacyPolicy -> {
intentManager.launchUri("https://bitwarden.com/privacy/".toUri())
}
is NavigateToTerms -> {
intentManager.launchUri("https://bitwarden.com/terms/".toUri())
}
is StartRegistrationEvent.NavigateToUnsubscribe -> {
intentManager.launchUri("https://bitwarden.com/email-preferences/".toUri())
}
is StartRegistrationEvent.NavigateBack -> onNavigateBack.invoke()
is StartRegistrationEvent.ShowToast -> {
Toast.makeText(context, event.text, Toast.LENGTH_SHORT).show()
}
is StartRegistrationEvent.NavigateToCompleteRegistration -> {
onNavigateToCompleteRegistration(
event.email,
event.verificationToken
)
}
is StartRegistrationEvent.NavigateToCheckEmail -> {
onNavigateToCheckEmail(
event.email
)
}
StartRegistrationEvent.NavigateToEnvironment -> onNavigateToEnvironment()
}
}
// Show dialog if needed:
when (val dialog = state.dialog) {
is StartRegistrationDialog.Error -> {
BitwardenBasicDialog(
visibilityState = dialog.state,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
)
}
StartRegistrationDialog.Loading -> {
BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(R.string.create_account.asText()),
)
}
null -> Unit
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.create_account),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(CloseClick) }
}
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.imePadding()
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(id = R.string.email_address),
value = state.emailInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(EmailInputChange(it)) }
},
modifier = Modifier
.testTag("EmailAddressEntry")
.fillMaxWidth()
.padding(horizontal = 16.dp),
keyboardType = KeyboardType.Email,
)
Spacer(modifier = Modifier.height(2.dp))
EnvironmentSelector(
labelText = stringResource(id = R.string.creating_on),
selectedOption = state.selectedEnvironmentType,
onOptionSelected = remember(viewModel) {
{ viewModel.trySendAction(StartRegistrationAction.EnvironmentTypeSelect(it)) }
},
modifier = Modifier
.testTag("RegionSelectorDropdown")
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(id = R.string.name),
value = state.nameInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(NameInputChange(it)) }
},
modifier = Modifier
.testTag("NameEntry")
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
if (state.selectedEnvironmentType != Environment.Type.SELF_HOSTED) {
ReceiveMarketingEmailsSwitch(
isChecked = state.isReceiveMarketingEmailsToggled,
onCheckedChange = remember(viewModel) {
{
viewModel.trySendAction(
StartRegistrationAction.ReceiveMarketingEmailsToggle(
it
)
)
}
},
onUnsubscribeClick = remember(viewModel) {
{ viewModel.trySendAction(StartRegistrationAction.UnsubscribeMarketingEmailsClick) }
}
)
Spacer(modifier = Modifier.height(16.dp))
}
BitwardenFilledButton(
label = stringResource(id = R.string.continue_text),
onClick = remember(viewModel) {
{ viewModel.trySendAction(StartRegistrationAction.ContinueClick) }
},
isEnabled = state.isContinueButtonEnabled,
modifier = Modifier
.testTag("ContinueButton")
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
TermsAndPrivacyText(
onTermsClick = remember(viewModel) {
{ viewModel.trySendAction(TermsClick) }
},
onPrivacyPolicyClick = remember(viewModel) {
{ viewModel.trySendAction(PrivacyPolicyClick) }
},
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Suppress("LongMethod")
@Composable
private fun TermsAndPrivacyText(
onTermsClick: () -> Unit,
onPrivacyPolicyClick: () -> Unit,
) {
val annotatedLinkString: AnnotatedString = buildAnnotatedString {
val strTermsAndPrivacy =
stringResource(id = R.string.by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy)
val strTerms = stringResource(id = R.string.terms_of_service)
val strPrivacy = stringResource(id = R.string.privacy_policy)
val startIndexTerms = strTermsAndPrivacy.indexOf(strTerms)
val endIndexTerms = startIndexTerms + strTerms.length
val startIndexPrivacy = strTermsAndPrivacy.indexOf(strPrivacy)
val endIndexPrivacy = startIndexPrivacy + strPrivacy.length
append(strTermsAndPrivacy)
addStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyMedium.fontSize
),
start = 0,
end = strTermsAndPrivacy.length
)
addStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold
),
start = startIndexTerms,
end = endIndexTerms
)
addStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold
),
start = startIndexPrivacy,
end = endIndexPrivacy
)
addStringAnnotation(
tag = "URL",
annotation = strTerms,
start = startIndexTerms,
end = endIndexTerms
)
addStringAnnotation(
tag = "URL",
annotation = strPrivacy,
start = startIndexPrivacy,
end = endIndexPrivacy
)
}
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.semantics(mergeDescendants = true) {
testTag = "DisclaimerText"
}
.fillMaxWidth(),
) {
val termsUrl = stringResource(id = R.string.terms_of_service)
Column(Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp)) {
ClickableText(
text = annotatedLinkString,
style = MaterialTheme.typography.bodyMedium,
onClick = {
annotatedLinkString
.getStringAnnotations("URL", it, it)
.firstOrNull()?.let { stringAnnotation ->
if (stringAnnotation.item == termsUrl)
onTermsClick()
else
onPrivacyPolicyClick()
}
}
)
}
}
}
@Suppress("LongMethod")
@Composable
private fun ReceiveMarketingEmailsSwitch(
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit,
onUnsubscribeClick: () -> Unit,
) {
val annotatedLinkString: AnnotatedString = buildAnnotatedString {
val strMarketingEmail =
stringResource(id = R.string.get_advice_announcements_and_research_opportunities_from_bitwarden_in_your_inbox_unsubscribe_any_time)
val strUnsubscribe = stringResource(id = R.string.unsubscribe)
val startIndexUnsubscribe = strMarketingEmail.indexOf(strUnsubscribe, ignoreCase = true)
val endIndexUnsubscribe = startIndexUnsubscribe + strUnsubscribe.length
append(strMarketingEmail)
addStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyMedium.fontSize
),
start = 0,
end = strMarketingEmail.length
)
addStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold
),
start = startIndexUnsubscribe,
end = endIndexUnsubscribe
)
addStringAnnotation(
tag = "URL",
annotation = strUnsubscribe,
start = startIndexUnsubscribe,
end = endIndexUnsubscribe
)
}
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.semantics(mergeDescendants = true) {
testTag = "ReceiveMarketingEmailsToggle"
toggleableState = ToggleableState(isChecked)
}
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = { onCheckedChange.invoke(!isChecked) },
)
.padding(start = 16.dp)
.fillMaxWidth(),
) {
Switch(
modifier = Modifier
.height(32.dp)
.width(52.dp),
checked = isChecked,
onCheckedChange = null,
)
Column(Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp)) {
ClickableText(
text = annotatedLinkString,
style = MaterialTheme.typography.bodyMedium,
onClick = {
annotatedLinkString
.getStringAnnotations("URL", it, it)
.firstOrNull()?.let {
onUnsubscribeClick()
}
}
)
}
}
}

View File

@@ -0,0 +1,398 @@
package com.x8bit.bitwarden.ui.auth.feature.startregistration
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ErrorDialogDismiss
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.NameInputChange
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.PrivacyPolicyClick
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.TermsClick
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Models logic for the start registration screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class StartRegistrationViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
) : BaseViewModel<StartRegistrationState, StartRegistrationEvent, StartRegistrationAction>(
initialState = savedStateHandle[KEY_STATE]
?: StartRegistrationState(
emailInput = "",
nameInput = "",
isReceiveMarketingEmailsToggled = environmentRepository.environment.type == Environment.Type.US,
isContinueButtonEnabled = false,
selectedEnvironmentType = environmentRepository.environment.type,
dialog = null,
),
) {
init {
// As state updates, write to saved state handle:
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
// Listen for changes in environment triggered both by this VM and externally.
environmentRepository
.environmentStateFlow
.onEach { environment ->
sendAction(
StartRegistrationAction.Internal.UpdatedEnvironmentReceive(environment = environment),
)
}
.launchIn(viewModelScope)
}
override fun handleAction(action: StartRegistrationAction) {
when (action) {
is StartRegistrationAction.ContinueClick -> handleContinueClick()
is EmailInputChange -> handleEmailInputChanged(action)
is NameInputChange -> handleNameInputChanged(action)
is CloseClick -> handleCloseClick()
is ErrorDialogDismiss -> handleDialogDismiss()
is StartRegistrationAction.ReceiveMarketingEmailsToggle -> handleReceiveMarketingEmailsToggle(
action
)
is PrivacyPolicyClick -> handlePrivacyPolicyClick()
is TermsClick -> handleTermsClick()
is StartRegistrationAction.UnsubscribeMarketingEmailsClick -> handleUnsubscribeMarketingEmailsClick()
is StartRegistrationAction.Internal.ReceiveSendVerificationEmailResult -> {
handleReceiveSendVerificationEmailResult(action)
}
is StartRegistrationAction.EnvironmentTypeSelect -> handleEnvironmentTypeSelect(action)
is StartRegistrationAction.Internal.UpdatedEnvironmentReceive -> {
handleUpdatedEnvironmentReceive(action)
}
}
}
private fun handleEnvironmentTypeSelect(action: StartRegistrationAction.EnvironmentTypeSelect) {
val environment = when (action.environmentType) {
Environment.Type.US -> Environment.Us
Environment.Type.EU -> Environment.Eu
Environment.Type.SELF_HOSTED -> {
// Launch the self-hosted screen and select the full environment details there.
sendEvent(StartRegistrationEvent.NavigateToEnvironment)
return
}
}
// Update the environment in the repo; the VM state will update accordingly because it is
// listening for changes.
environmentRepository.environment = environment
}
private fun handleUpdatedEnvironmentReceive(
action: StartRegistrationAction.Internal.UpdatedEnvironmentReceive,
) {
mutableStateFlow.update {
it.copy(
selectedEnvironmentType = action.environment.type,
)
}
}
private fun handlePrivacyPolicyClick() =
sendEvent(StartRegistrationEvent.NavigateToPrivacyPolicy)
private fun handleTermsClick() = sendEvent(StartRegistrationEvent.NavigateToTerms)
private fun handleUnsubscribeMarketingEmailsClick() =
sendEvent(StartRegistrationEvent.NavigateToUnsubscribe)
private fun handleReceiveMarketingEmailsToggle(action: StartRegistrationAction.ReceiveMarketingEmailsToggle) {
mutableStateFlow.update {
it.copy(isReceiveMarketingEmailsToggled = action.newState)
}
}
private fun handleDialogDismiss() {
mutableStateFlow.update {
it.copy(dialog = null)
}
}
private fun handleCloseClick() {
sendEvent(StartRegistrationEvent.NavigateBack)
}
private fun handleEmailInputChanged(action: EmailInputChange) {
mutableStateFlow.update {
it.copy(
emailInput = action.input,
isContinueButtonEnabled = action.input.isNotBlank()
)
}
}
private fun handleNameInputChanged(action: NameInputChange) {
mutableStateFlow.update {
it.copy(
nameInput = action.input,
isContinueButtonEnabled = state.emailInput.isNotBlank()
)
}
}
private fun handleContinueClick() = when {
state.emailInput.isBlank() -> {
val dialog = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.email_address.asText()),
)
mutableStateFlow.update { it.copy(dialog = StartRegistrationDialog.Error(dialog)) }
}
!state.emailInput.isValidEmail() -> {
val dialog = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_email.asText(),
)
mutableStateFlow.update { it.copy(dialog = StartRegistrationDialog.Error(dialog)) }
}
else -> {
submitSendVerificationEmailRequest()
}
}
private fun submitSendVerificationEmailRequest() {
mutableStateFlow.update {
it.copy(dialog = StartRegistrationDialog.Loading)
}
viewModelScope.launch {
val result = authRepository.sendVerificationEmail(
email = state.emailInput,
name = state.nameInput,
receiveMarketingEmails = state.isReceiveMarketingEmailsToggled,
)
sendAction(
StartRegistrationAction.Internal.ReceiveSendVerificationEmailResult(
sendVerificationEmailResult = result,
),
)
}
}
private fun handleReceiveSendVerificationEmailResult(result: StartRegistrationAction.Internal.ReceiveSendVerificationEmailResult) {
when (val sendVerificationEmailResult = result.sendVerificationEmailResult) {
is SendVerificationEmailResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = StartRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = sendVerificationEmailResult.errorMessage?.asText()
?: R.string.generic_error_message.asText(),
),
),
)
}
}
is SendVerificationEmailResult.Success -> {
environmentRepository.saveCurrentEnvironmentForEmail(state.emailInput)
mutableStateFlow.update { it.copy(dialog = null) }
if (sendVerificationEmailResult.emailVerificationToken == null)
sendEvent(
StartRegistrationEvent.NavigateToCheckEmail(
email = state.emailInput
)
)
else
sendEvent(
StartRegistrationEvent.NavigateToCompleteRegistration(
email = state.emailInput,
verificationToken = sendVerificationEmailResult.emailVerificationToken
)
)
}
}
}
}
/**
* UI state for the start registration screen.
*/
@Parcelize
data class StartRegistrationState(
val emailInput: String,
val nameInput: String,
val isReceiveMarketingEmailsToggled: Boolean,
val isContinueButtonEnabled: Boolean,
val selectedEnvironmentType: Environment.Type,
val dialog: StartRegistrationDialog?,
) : Parcelable
/**
* Models dialogs that can be displayed on the start registration screen.
*/
sealed class StartRegistrationDialog : Parcelable {
/**
* Loading dialog.
*/
@Parcelize
data object Loading : StartRegistrationDialog()
/**
* General error dialog with an OK button.
*/
@Parcelize
data class Error(val state: BasicDialogState.Shown) : StartRegistrationDialog()
}
/**
* Models events for the start registration screen.
*/
sealed class StartRegistrationEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : StartRegistrationEvent()
/**
* Placeholder event for showing a toast. Can be removed once there are real events.
*/
data class ShowToast(val text: String) : StartRegistrationEvent()
/**
* Navigates to the complete registration screen.
*/
data class NavigateToCompleteRegistration(
val email: String,
val verificationToken: String,
) : StartRegistrationEvent()
/**
* Navigates to the check email screen.
*/
data class NavigateToCheckEmail(
val email: String,
) : StartRegistrationEvent()
/**
* Navigate to terms and conditions.
*/
data object NavigateToTerms : StartRegistrationEvent()
/**
* Navigate to privacy policy.
*/
data object NavigateToPrivacyPolicy : StartRegistrationEvent()
/**
* Navigate to unsubscribe to marketing emails.
*/
data object NavigateToUnsubscribe : StartRegistrationEvent()
/**
* Navigates to the self-hosted/custom environment screen.
*/
data object NavigateToEnvironment : StartRegistrationEvent()
}
/**
* Models actions for the start registration screen.
*/
sealed class StartRegistrationAction {
/**
* User clicked continue.
*/
data object ContinueClick : StartRegistrationAction()
/**
* User clicked close.
*/
data object CloseClick : StartRegistrationAction()
/**
* Email input changed.
*/
data class EmailInputChange(val input: String) : StartRegistrationAction()
/**
* Name input changed.
*/
data class NameInputChange(val input: String) : StartRegistrationAction()
/**
* Indicates that the selection from the region drop down has changed.
*/
data class EnvironmentTypeSelect(
val environmentType: Environment.Type,
) : StartRegistrationAction()
/**
* User dismissed the error dialog.
*/
data object ErrorDialogDismiss : StartRegistrationAction()
/**
* User tapped receive marketing emails toggle.
*/
data class ReceiveMarketingEmailsToggle(val newState: Boolean) : StartRegistrationAction()
/**
* User tapped privacy policy link.
*/
data object PrivacyPolicyClick : StartRegistrationAction()
/**
* User tapped terms link.
*/
data object TermsClick : StartRegistrationAction()
/**
* User tapped the unsubscribe link.
*/
data object UnsubscribeMarketingEmailsClick : StartRegistrationAction()
/**
* Models actions that the [StartRegistrationViewModel] itself might send.
*/
sealed class Internal : StartRegistrationAction() {
/**
* Indicates a [RegisterResult] has been received.
*/
data class ReceiveSendVerificationEmailResult(
val sendVerificationEmailResult: SendVerificationEmailResult,
) : Internal()
/**
* Indicates that there has been a change in [environment].
*/
data class UpdatedEnvironmentReceive(
val environment: Environment,
) : Internal()
}
}

View File

@@ -0,0 +1,110 @@
package com.x8bit.bitwarden.ui.platform.components.dropdown
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenSelectionRow
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.util.displayLabel
/**
* A dropdown selector UI component specific to region url selection.
*
* This composable displays a dropdown menu allowing users to select a region
* from a list of options. When an option is selected, it invokes the provided callback
* and displays the currently selected region on the UI.
*
* @param labelText The text displayed near the selector button.
* @param selectedOption The currently selected environment option.
* @param onOptionSelected A callback that gets invoked when an environment option is selected
* and passes the selected option as an argument.
* @param modifier A [Modifier] for the composable.
*
*/
@Composable
fun EnvironmentSelector(
labelText: String,
selectedOption: Environment.Type,
onOptionSelected: (Environment.Type) -> Unit,
modifier: Modifier = Modifier,
) {
val options = Environment.Type.entries.toTypedArray()
var shouldShowDialog by rememberSaveable { mutableStateOf(false) }
Box(modifier = modifier) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.clickable(
indication = rememberRipple(
bounded = true,
color = MaterialTheme.colorScheme.primary,
),
interactionSource = remember { MutableInteractionSource() },
onClick = { shouldShowDialog = !shouldShowDialog },
)
.padding(
vertical = 8.dp,
horizontal = 16.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = labelText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 12.dp),
)
Text(
text = selectedOption.displayLabel(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp),
)
Icon(
painter = rememberVectorPainter(id = R.drawable.ic_region_select_dropdown),
contentDescription = stringResource(id = R.string.region),
tint = MaterialTheme.colorScheme.primary,
)
}
if (shouldShowDialog) {
BitwardenSelectionDialog(
title = stringResource(id = R.string.logging_in_on),
onDismissRequest = { shouldShowDialog = false },
) {
options.forEach {
BitwardenSelectionRow(
text = it.displayLabel,
onClick = {
onOptionSelected.invoke(it)
shouldShowDialog = false
},
isSelected = it == selectedOption,
)
}
}
}
}
}

View File

@@ -18,6 +18,7 @@ import androidx.navigation.navOptions
import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph
import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.RESET_PASSWORD_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination
@@ -95,7 +96,10 @@ fun RootNavScreen(
}
val targetRoute = when (state) {
RootNavState.Auth -> AUTH_GRAPH_ROUTE
RootNavState.Auth,
is RootNavState.CompleteOngoingRegistration,
-> AUTH_GRAPH_ROUTE
RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE
RootNavState.SetPassword -> SET_PASSWORD_ROUTE
RootNavState.Splash -> SPLASH_ROUTE
@@ -192,6 +196,15 @@ fun RootNavScreen(
navOptions = rootNavOptions,
)
}
is RootNavState.CompleteOngoingRegistration -> {
navController.navigateToAuthGraph(rootNavOptions)
navController.navigateToCompleteRegistration(
emailAddress = currentState.email,
verificationToken = currentState.verificationToken,
fromEmail = currentState.fromEmail
)
}
}
}
}

View File

@@ -60,6 +60,7 @@ class RootNavViewModel @Inject constructor(
action: RootNavAction.Internal.UserStateUpdateReceive,
) {
val userState = action.userState
val specialCircumstance = action.specialCircumstance
val updatedRootNavState = when {
userState?.activeAccount?.trustedDevice?.isDeviceTrusted == false &&
!userState.activeAccount.isVaultUnlocked &&
@@ -69,12 +70,26 @@ class RootNavViewModel @Inject constructor(
userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword
specialCircumstance is SpecialCircumstance.CompleteRegistration -> {
// When the user is on the lock screen or already in the vault
if (userState?.activeAccount != null && !authRepository.hasPendingAccountAddition) {
authRepository.hasPendingAccountAddition = true
return
}
RootNavState.CompleteOngoingRegistration(
email = specialCircumstance.completeRegistrationData.email,
verificationToken = specialCircumstance.completeRegistrationData.verificationToken,
fromEmail = specialCircumstance.completeRegistrationData.fromEmail,
timestamp = specialCircumstance.timestamp
)
}
userState == null ||
!userState.activeAccount.isLoggedIn ||
userState.hasPendingAccountAddition -> RootNavState.Auth
userState.activeAccount.isVaultUnlocked -> {
when (val specialCircumstance = action.specialCircumstance) {
when (specialCircumstance) {
is SpecialCircumstance.AutofillSave -> {
RootNavState.VaultUnlockedForAutofillSave(
autofillSaveItem = specialCircumstance.autofillSaveItem,
@@ -105,6 +120,14 @@ class RootNavViewModel @Inject constructor(
SpecialCircumstance.VaultShortcut,
null,
-> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId)
is SpecialCircumstance.CompleteRegistration ->
RootNavState.CompleteOngoingRegistration(
email = specialCircumstance.completeRegistrationData.email,
verificationToken = specialCircumstance.completeRegistrationData.verificationToken,
fromEmail = specialCircumstance.completeRegistrationData.fromEmail,
timestamp = specialCircumstance.timestamp
)
}
}
@@ -200,6 +223,17 @@ sealed class RootNavState : Parcelable {
@Parcelize
data object VaultUnlockedForNewSend : RootNavState()
/**
* App should show the screen to complete an ongoing registration process.
*/
@Parcelize
data class CompleteOngoingRegistration(
val email: String,
val verificationToken: String,
val fromEmail: Boolean,
val timestamp: Long,
) : RootNavState()
/**
* App should show the auth confirmation screen for an unlocked user.
*/

View File

@@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthIndicator
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthIndicator
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledTonalButton

View File

@@ -15,7 +15,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText

View File

@@ -92,6 +92,11 @@ interface IntentManager {
): PendingIntent
/**
* Open the default email app on device.
*/
fun openEmailApp()
/**
* Creates a pending intent to use when providing
* [androidx.credentials.provider.CredentialEntry] instances for FIDO 2 credential filling.
*/

View File

@@ -117,6 +117,13 @@ class IntentManagerImpl(
startActivity(intent = intent)
}
override fun openEmailApp() {
val intent = Intent(Intent.ACTION_MAIN)
intent.addCategory(Intent.CATEGORY_APP_EMAIL)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
override fun launchUri(uri: Uri) {
val newUri = if (uri.scheme == null) {
uri.buildUpon().scheme("https").build()

View File

@@ -0,0 +1,84 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="413dp"
android:height="114dp"
android:viewportWidth="413"
android:viewportHeight="114">
<group>
<clip-path
android:pathData="M134.84,0.57h143.82v112.71h-143.82z"/>
<path
android:pathData="M260.14,44.62V59.04M192.86,14.74L201.04,8.64C204.02,6.42 208.12,6.45 211.07,8.73L212.84,10.09M164.61,35.82L156.15,42.14C154.05,43.7 152.82,46.17 152.82,48.79V100.48C152.82,105.07 156.53,108.78 161.11,108.78H251.85C256.43,108.78 260.14,105.07 260.14,100.48V71.05"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M165.38,54.52V17.83C165.38,16.3 166.61,15.07 168.14,15.07H206.8"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M175.32,27.21H194.11"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M175.32,38.39H193.56"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M175.32,49.57H196.57"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M175.32,60.75H203.13"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M257.86,106.5L223.49,74.77C220.93,72.41 217.59,71.1 214.11,71.1H197.24C193.65,71.1 190.2,72.5 187.62,75L155.1,106.5"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"/>
<path
android:pathData="M220.36,71.58L231.04,65.46"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M153.86,48.45L192.59,71.58"
android:strokeWidth="2.77"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M260.35,35.09C260.35,51.78 246.82,65.3 230.13,65.3C213.45,65.3 199.92,51.78 199.92,35.09C199.92,18.4 213.45,4.88 230.13,4.88C246.82,4.88 260.35,18.4 260.35,35.09Z"
android:strokeLineJoin="round"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M256.3,35.09C256.3,49.44 244.53,61.07 230.02,61.07M230.02,9.11C215.5,9.11 203.73,20.74 203.73,35.09"
android:strokeLineJoin="round"
android:strokeWidth="1.38289"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
<path
android:pathData="M254.25,53.55L258.87,58.17L276.21,75.51C277.56,76.86 277.56,79.05 276.21,80.4L275.49,81.12C274.14,82.47 271.95,82.47 270.6,81.12L253.26,63.78L248.64,59.16"
android:strokeLineJoin="round"
android:strokeWidth="2.76577"
android:fillColor="#00000000"
android:strokeColor="#175DDC"
android:strokeLineCap="round"/>
</group>
</vector>

View File

@@ -913,6 +913,18 @@ Do you want to switch to this account?</string>
<string name="passkey_operation_failed_because_of_missing_asset_links">Passkey operation failed because of missing asset links</string>
<string name="passkey_operation_failed_because_app_not_found_in_asset_links">Passkey operation failed because app not found in asset links</string>
<string name="passkey_operation_failed_because_app_could_not_be_verified">Passkey operation failed because app could not be verified</string>
<string name="creating_on">Creating on:</string>
<string name="follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account">Follow the instructions in the email sent to %1$s to continue creating your account.</string>
<string name="by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy">By continuing, you agree to the Terms of Service and Privacy Policy</string>
<string name="set_password">Set password</string>
<string name="unsubscribe">Unsubscribe</string>
<string name="check_your_email">Check your email</string>
<string name="open_email_app">Open email app</string>
<string name="go_back">Go back</string>
<string name="email_verified">Email verified</string>
<string name="no_email_go_back_to_edit_your_email_address">No email? Go back to edit your email address.</string>
<string name="or_log_in_you_may_already_have_an_account">Or log in, you may already have an account.</string>
<string name="get_advice_announcements_and_research_opportunities_from_bitwarden_in_your_inbox_unsubscribe_any_time">Get advice, announcements, and research opportunities from Bitwarden in your inbox. Unsubscribe at any time.</string>
<string name="confirm_file_password">Confirm file password</string>
<string name="continue_to_give_feedback">Continue to Give Feedback?</string>
<string name="continue_to_provide_feedback">Select continue to give feedback on the new app!</string>

View File

@@ -8,6 +8,7 @@ import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
@@ -255,6 +256,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
@@ -281,6 +283,7 @@ class MainViewModelTest : BaseViewModelTest() {
val autofillSelectionData = mockk<AutofillSelectionData>()
every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns autofillSelectionData
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
@@ -309,6 +312,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null
every { mockIntent.getAutofillSaveItemOrNull() } returns autofillSaveItem
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
@@ -337,6 +341,7 @@ class MainViewModelTest : BaseViewModelTest() {
} returns passwordlessRequestData
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
@@ -366,6 +371,7 @@ class MainViewModelTest : BaseViewModelTest() {
signingInfo = SigningInfo(),
origin = "mockOrigin",
)
val fido2Intent = createMockFido2RegistrationIntent(fido2CredentialRequest)
every { intentManager.getShareDataFromIntent(fido2Intent) } returns null
@@ -420,6 +426,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
every { getCompleteRegistrationDataIntentOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns false
}
@@ -479,6 +486,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
@@ -505,6 +513,7 @@ class MainViewModelTest : BaseViewModelTest() {
val autofillSelectionData = mockk<AutofillSelectionData>()
every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns autofillSelectionData
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
@@ -533,6 +542,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null
every { mockIntent.getAutofillSaveItemOrNull() } returns autofillSaveItem
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
@@ -561,6 +571,7 @@ class MainViewModelTest : BaseViewModelTest() {
} returns passwordlessRequestData
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
@@ -587,6 +598,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { getCompleteRegistrationDataIntentOrNull() } returns null
every { isMyVaultShortcut } returns true
every { isPasswordGeneratorShortcut } returns false
}
@@ -611,6 +623,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { getCompleteRegistrationDataIntentOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns true
}
@@ -692,6 +705,7 @@ private fun createMockFido2RegistrationIntent(
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
every { getCompleteRegistrationDataIntentOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns false
}

View File

@@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOpti
import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
import io.mockk.every
@@ -1042,6 +1043,44 @@ class AuthDiskSourceTest {
json.parseToJsonElement(requireNotNull(actual)),
)
}
@Test
fun `getEmailVerificationUrls should pull from SharedPreferences`() {
val emailVerificationUrlsBaseKey = "bwPreferencesStorage:emailVerificationUrls"
val mockUserEmail = "mockUserEmail"
val mockUrls = Environment.Us.environmentUrlData
fakeSharedPreferences
.edit {
putString(
"${emailVerificationUrlsBaseKey}_$mockUserEmail",
json.encodeToString(mockUrls),
)
}
val actual = authDiskSource.getEmailVerificationUrls(userEmail = mockUserEmail)
assertEquals(
mockUrls,
actual,
)
}
@Test
fun `storeEmailVerificationUrls should update SharedPreferences`() {
val emailVerificationUrlsBaseKey = "bwPreferencesStorage:emailVerificationUrls"
val mockUserEmail = "mockUserEmail"
val mockUrls = Environment.Us.environmentUrlData
authDiskSource.storeEmailVerificationUrls(
userEmail = mockUserEmail,
urls = mockUrls,
)
val actual = fakeSharedPreferences.getString(
"${emailVerificationUrlsBaseKey}_$mockUserEmail",
null,
)
assertEquals(
json.encodeToJsonElement(mockUrls),
json.parseToJsonElement(requireNotNull(actual)),
)
}
}
private const val USER_STATE_JSON = """

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.disk.util
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
@@ -44,6 +45,7 @@ class FakeAuthDiskSource : AuthDiskSource {
private val storedBiometricKeys = mutableMapOf<String, String?>()
private val storedMasterPasswordHashes = mutableMapOf<String, String?>()
private val storedPolicies = mutableMapOf<String, List<SyncResponseJson.Policy>?>()
private val storedEmailVerificationUrls = mutableMapOf<String, EnvironmentUrlDataJson?>()
override var userState: UserStateJson? = null
set(value) {
@@ -226,6 +228,13 @@ class FakeAuthDiskSource : AuthDiskSource {
getMutableAccountTokensFlow(userId = userId).tryEmit(accountTokens)
}
override fun getEmailVerificationUrls(userEmail: String): EnvironmentUrlDataJson? =
storedEmailVerificationUrls[userEmail]
override fun storeEmailVerificationUrls(userEmail: String, urls: EnvironmentUrlDataJson) {
storedEmailVerificationUrls[userEmail] = urls
}
/**
* Assert that the given [userState] matches the currently tracked value.
*/

View File

@@ -147,6 +147,41 @@ class EnvironmentRepositoryTest {
}
}
@Test
fun `loadEnvironmentForEmail should update the environment`() = runTest {
val environmentUrlDataJson = mockk<EnvironmentUrlDataJson>()
val environment = mockk<Environment> {
every { environmentUrlData } returns environmentUrlDataJson
}
every { environmentUrlDataJson.toEnvironmentUrls() } returns environment
fakeAuthDiskSource.storeEmailVerificationUrls("email@example.com", environmentUrlDataJson)
repository.loadEnvironmentForEmail("email@example.com")
assertEquals(
environment,
repository.environment,
)
}
@Test
fun `saveCurrentEnvironmentForEmail should save the environment`() = runTest {
val environmentUrlDataJson = mockk<EnvironmentUrlDataJson>()
val environment = mockk<Environment> {
every { environmentUrlData } returns environmentUrlDataJson
}
every { environmentUrlDataJson.toEnvironmentUrls() } returns environment
repository.environment = Environment.Eu
fakeAuthDiskSource.storeEmailVerificationUrls("email@example.com", environmentUrlDataJson)
repository.saveCurrentEnvironmentForEmail("email@example.com")
assertEquals(
Environment.Eu.environmentUrlData,
fakeAuthDiskSource.getEmailVerificationUrls("email@example.com"),
)
}
private fun getMockUserState(
environmentForActiveUser: EnvironmentUrlDataJson?,
): UserStateJson =

View File

@@ -10,6 +10,9 @@ import kotlinx.coroutines.flow.asStateFlow
* A faked implementation of [EnvironmentRepository] based on in-memory caching.
*/
class FakeEnvironmentRepository : EnvironmentRepository {
private var saveCurrentEnvironmentForEmailCalled = false
private var loadEnvironmentForEmailCalled = false
override var environment: Environment
get() = mutableEnvironmentStateFlow.value
set(value) {
@@ -18,5 +21,14 @@ class FakeEnvironmentRepository : EnvironmentRepository {
override val environmentStateFlow: StateFlow<Environment>
get() = mutableEnvironmentStateFlow.asStateFlow()
override fun saveCurrentEnvironmentForEmail(userEmail: String) {
saveCurrentEnvironmentForEmailCalled = true
}
override fun loadEnvironmentForEmail(userEmail: String): Boolean {
loadEnvironmentForEmailCalled = true
return true
}
private val mutableEnvironmentStateFlow = MutableStateFlow<Environment>(Environment.Us)
}

View File

@@ -0,0 +1,90 @@
package com.x8bit.bitwarden.ui.auth.feature.checkemail
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
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
class CheckEmailModelTest : BaseViewModelTest() {
@BeforeEach
fun setUp() {
}
@AfterEach
fun tearDown() {
}
@Test
fun `initial state should be correct`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Test
fun `initial state should pull from handle when present`() = runTest {
val expectedState = DEFAULT_STATE.copy(
email = "another@email.com"
)
val viewModel = createViewModel(expectedState)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `CloseTap should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CheckEmailAction.CloseTap)
assertEquals(
CheckEmailEvent.NavigateBack,
awaitItem(),
)
}
}
@Test
fun `LoginTap should emit NavigateBackToLanding`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CheckEmailAction.LoginTap)
assertEquals(
CheckEmailEvent.NavigateBackToLanding,
awaitItem(),
)
}
}
@Test
fun `OpenEmailTap should emit NavigateToEmailApp`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CheckEmailAction.OpenEmailTap)
assertEquals(
CheckEmailEvent.NavigateToEmailApp,
awaitItem(),
)
}
}
private fun createViewModel(state: CheckEmailState? = null): CheckEmailViewModel =
CheckEmailViewModel(
savedStateHandle = SavedStateHandle().also {
it["email"] = EMAIL
it["state"] = state
},
)
companion object {
private const val EMAIL = "test@gmail.com"
private val DEFAULT_STATE = CheckEmailState(
email = EMAIL,
)
}
}

View File

@@ -0,0 +1,88 @@
package com.x8bit.bitwarden.ui.auth.feature.checkemail
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import junit.framework.TestCase
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test
class CheckEmailScreenTest : BaseComposeTest() {
private val intentManager = mockk<IntentManager>(relaxed = true) {
every { openEmailApp() } just runs
}
private var onNavigateBackCalled = false
private var onNavigateBackToLandingCalled = false
private var onNavigateToEmailAppCalled = false
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<CheckEmailEvent>()
private val viewModel = mockk<CheckEmailViewModel>(relaxed = true) {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
CheckEmailScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateBackToLanding = { onNavigateBackToLandingCalled = true },
viewModel = viewModel,
intentManager = intentManager,
)
}
}
@Test
fun `close button click should send CloseTap action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify {
viewModel.trySendAction(CheckEmailAction.CloseTap)
}
}
@Test
fun `open email app button click should send OpenEmailTap action`() {
composeTestRule.onNodeWithText("Open email app").performClick()
verify {
viewModel.trySendAction(CheckEmailAction.OpenEmailTap)
}
}
@Test
fun `login button click should send LoginTap action`() {
mutableEventFlow.tryEmit(CheckEmailEvent.NavigateBackToLanding)
TestCase.assertTrue(onNavigateBackToLandingCalled)
}
@Test
fun `NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(CheckEmailEvent.NavigateBack)
TestCase.assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToEmailApp should call openEmailApp`() {
mutableEventFlow.tryEmit(CheckEmailEvent.NavigateToEmailApp)
verify {
intentManager.openEmailApp()
}
}
companion object {
private const val EMAIL = "test@gmail.com"
private val DEFAULT_STATE = CheckEmailState(
email = EMAIL,
)
}
}

View File

@@ -0,0 +1,257 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import android.net.Uri
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CreateAccountClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class CompleteRegistrationScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToLandingCalled = false
private val intentManager = mockk<IntentManager>(relaxed = true) {
every { startCustomTabsActivity(any()) } just runs
every { startActivity(any()) } just runs
}
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<CompleteRegistrationEvent>()
private val viewModel = mockk<CompleteRegistrationViewModel>(relaxed = true) {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
every { trySendAction(any()) } just runs
}
@Before
fun setup() {
composeTestRule.setContent {
CompleteRegistrationScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToLanding = { onNavigateToLandingCalled = true },
intentManager = intentManager,
viewModel = viewModel,
)
}
}
@Test
fun `app bar submit click should send CreateAccountClick action`() {
composeTestRule.onNodeWithText("Create account").performClick()
verify { viewModel.trySendAction(CreateAccountClick) }
}
@Test
fun `close click should send CloseClick action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify { viewModel.trySendAction(CloseClick) }
}
@Test
fun `check data breaches click should send CheckDataBreachesToggle action`() {
composeTestRule
.onNodeWithText("Check known data breaches for this password")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(CheckDataBreachesToggle(false)) }
}
@Test
fun `NavigateBack event should invoke navigate back lambda`() {
mutableEventFlow.tryEmit(CompleteRegistrationEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToLogin event should invoke navigate login lambda`() {
mutableEventFlow.tryEmit(CompleteRegistrationEvent.NavigateToLanding)
assertTrue(onNavigateToLandingCalled)
}
@Test
fun `NavigateToCaptcha event should invoke intent manager`() {
val mockUri = mockk<Uri>()
mutableEventFlow.tryEmit(CompleteRegistrationEvent.NavigateToCaptcha(uri = mockUri))
verify {
intentManager.startCustomTabsActivity(mockUri)
}
}
@Test
fun `password input change should send PasswordInputChange action`() {
composeTestRule.onNodeWithText("Master password").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(PasswordInputChange(TEST_INPUT)) }
}
@Test
fun `confirm password input change should send ConfirmPasswordInputChange action`() {
composeTestRule.onNodeWithText("Re-type master password").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(ConfirmPasswordInputChange(TEST_INPUT)) }
}
@Test
fun `password hint input change should send PasswordHintChange action`() {
composeTestRule
.onNodeWithText("Master password hint (optional)")
.performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(PasswordHintChange(TEST_INPUT)) }
}
@Test
fun `clicking OK on the error dialog should send ErrorDialogDismiss action`() {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
),
),
)
}
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(CompleteRegistrationAction.ErrorDialogDismiss) }
}
@Test
fun `clicking No on the HIBP dialog should send ErrorDialogDismiss action`() {
mutableStateFlow.update {
it.copy(dialog = createHaveIBeenPwned())
}
composeTestRule
.onAllNodesWithText("No")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(CompleteRegistrationAction.ErrorDialogDismiss) }
}
@Test
fun `clicking Yes on the HIBP dialog should send ContinueWithBreachedPasswordClick action`() {
mutableStateFlow.update {
it.copy(dialog = createHaveIBeenPwned())
}
composeTestRule
.onAllNodesWithText("Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(CompleteRegistrationAction.ContinueWithBreachedPasswordClick) }
}
@Test
fun `when BasicDialogState is Shown should show dialog`() {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
),
),
)
}
composeTestRule.onNode(isDialog()).assertIsDisplayed()
}
@Test
fun `password strength should change as state changes`() {
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_1)
}
composeTestRule.onNodeWithText("Weak").assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_2)
}
composeTestRule.onNodeWithText("Weak").assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_3)
}
composeTestRule.onNodeWithText("Weak").assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.GOOD)
}
composeTestRule.onNodeWithText("Good").assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.STRONG)
}
composeTestRule.onNodeWithText("Strong").assertIsDisplayed()
}
@Test
fun `toggling one password field visibility should toggle the other`() {
// should start with 2 Show buttons:
composeTestRule
.onAllNodesWithContentDescription("Show")
.assertCountEquals(2)[0]
.performClick()
// after clicking there should be no Show buttons:
composeTestRule
.onAllNodesWithContentDescription("Show")
.assertCountEquals(0)
// and there should be 2 hide buttons now, and we'll click the second one:
composeTestRule
.onAllNodesWithContentDescription("Hide")
.assertCountEquals(2)[1]
.performClick()
// then there should be two show buttons again
composeTestRule
.onAllNodesWithContentDescription("Show")
.assertCountEquals(2)
}
companion object {
private const val EMAIL = "test@test.com"
private const val TOKEN = "token"
private const val TEST_INPUT = "input"
private val DEFAULT_STATE = CompleteRegistrationState(
userEmail = EMAIL,
emailVerificationToken = TOKEN,
fromEmail = true,
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isCheckDataBreachesToggled = true,
dialog = null,
passwordStrengthState = PasswordStrengthState.NONE,
)
}
}

View File

@@ -0,0 +1,18 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
/**
* Creates a mock [CompleteRegistrationDialog.HaveIBeenPwned].
*/
fun createHaveIBeenPwned(
title: Text = R.string.weak_and_exposed_master_password.asText(),
message: Text = R.string.weak_password_identified_and_found_in_a_data_breach_alert_description
.asText(),
): CompleteRegistrationDialog.HaveIBeenPwned =
CompleteRegistrationDialog.HaveIBeenPwned(
title = title,
message = message,
)

View File

@@ -0,0 +1,588 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import app.cash.turbine.turbineScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_0
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_1
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_2
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_3
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal.ReceivePasswordStrengthResult
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class CompleteRegistrationViewModelTest : BaseViewModelTest() {
/**
* Saved state handle that has valid inputs. Useful for tests that want to test things
* after the user has entered all valid inputs.
*/
private val mockAuthRepository = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
}
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
private val specialCircumstanceManager: SpecialCircumstanceManager =
SpecialCircumstanceManagerImpl()
private var viewmodelVerifyEmailCalled = false
@BeforeEach
fun setUp() {
mockkStatic(::generateUriForCaptcha)
}
@AfterEach
fun tearDown() {
unmockkStatic(::generateUriForCaptcha)
}
@Test
fun `initial state should be correct`() {
val viewModel = createCompleteRegistrationViewModel(DEFAULT_STATE)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `onCleared should erase specialCircumstance`() = runTest {
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.CompleteRegistration(
completeRegistrationData = CompleteRegistrationData(
email = EMAIL,
verificationToken = TOKEN,
fromEmail = true
),
System.currentTimeMillis()
)
val viewModel = CompleteRegistrationViewModel(
savedStateHandle = SavedStateHandle(mapOf("state" to DEFAULT_STATE)),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository,
specialCircumstance = specialCircumstanceManager
)
viewModel.onCleared()
assertTrue(specialCircumstanceManager.specialCircumstance == null)
}
@Test
fun `CreateAccountClick with password below 12 chars should show password length dialog`() =
runTest {
val input = "abcdefghikl"
coEvery {
mockAuthRepository.getPasswordStrength("test@test.com", input)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(input))
val expectedState = DEFAULT_STATE.copy(
passwordInput = input,
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(12),
),
),
)
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `CreateAccountClick with passwords not matching should show password match dialog`() =
runTest {
val input = "testtesttesttest"
coEvery {
mockAuthRepository.getPasswordStrength(EMAIL, input)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(input))
val expectedState = DEFAULT_STATE.copy(
userEmail = EMAIL,
passwordInput = input,
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
),
),
)
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `CreateAccountClick with all inputs valid should show and hide loading dialog`() = runTest {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Success(captchaToken = "mock_token")
}
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo)
turbineScope {
val stateFlow = viewModel.stateFlow.testIn(backgroundScope)
val eventFlow = viewModel.eventFlow.testIn(backgroundScope)
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
assertEquals(
VALID_INPUT_STATE.copy(dialog = CompleteRegistrationDialog.Loading),
stateFlow.awaitItem(),
)
assertEquals(
CompleteRegistrationEvent.NavigateToLanding,
eventFlow.awaitItem(),
)
// Make sure loading dialog is hidden:
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
}
}
@Test
fun `CreateAccountClick register returns error should update errorDialogState`() = runTest {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Error(errorMessage = "mock_error")
}
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo)
viewModel.stateFlow.test {
assertEquals(VALID_INPUT_STATE, awaitItem())
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
assertEquals(
VALID_INPUT_STATE.copy(dialog = CompleteRegistrationDialog.Loading),
awaitItem(),
)
assertEquals(
VALID_INPUT_STATE.copy(
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = "mock_error".asText(),
),
),
),
awaitItem(),
)
}
}
@Test
fun `CreateAccountClick register returns CaptchaRequired should emit NavigateToCaptcha`() =
runTest {
val mockkUri = mockk<Uri>()
every {
generateUriForCaptcha(captchaId = "mock_captcha_id")
} returns mockkUri
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.CaptchaRequired(captchaId = "mock_captcha_id")
}
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo)
viewModel.eventFlow.test {
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
assertEquals(
CompleteRegistrationEvent.NavigateToCaptcha(uri = mockkUri),
awaitItem(),
)
}
}
@Test
fun `CreateAccountClick register returns Success should emit NavigateToLogin`() = runTest {
val mockkUri = mockk<Uri>()
every {
generateUriForCaptcha(captchaId = "mock_captcha_id")
} returns mockkUri
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Success(captchaToken = "mock_captcha_token")
}
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo)
viewModel.eventFlow.test {
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
assertEquals(
CompleteRegistrationEvent.NavigateToLanding,
awaitItem(),
)
}
}
@Test
fun `ContinueWithBreachedPasswordClick should call repository with checkDataBreaches false`() {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Error(null)
}
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo)
viewModel.trySendAction(CompleteRegistrationAction.ContinueWithBreachedPasswordClick)
coVerify {
repo.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
}
}
@Test
fun `CreateAccountClick register returns ShowDataBreaches should show HaveIBeenPwned dialog`() =
runTest {
mockAuthRepository.apply {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = true,
isMasterPasswordStrong = true,
)
} returns RegisterResult.DataBreachFound
}
val initialState = VALID_INPUT_STATE.copy(
isCheckDataBreachesToggled = true,
)
val viewModel = createCompleteRegistrationViewModel(
completeRegistrationState = initialState
)
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
dialog = createHaveIBeenPwned(
title = R.string.exposed_master_password.asText(),
message = R.string.password_found_in_a_data_breach_alert_description
.asText(),
),
),
awaitItem(),
)
}
}
@Test
@Suppress("MaxLineLength")
fun `CreateAccountClick register returns DataBreachAndWeakPassword should show HaveIBeenPwned dialog`() =
runTest {
mockAuthRepository.apply {
every { captchaTokenResultFlow } returns emptyFlow()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = true,
isMasterPasswordStrong = false,
)
} returns RegisterResult.DataBreachAndWeakPassword
}
val initialState = VALID_INPUT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_1,
isCheckDataBreachesToggled = true,
)
val viewModel =
createCompleteRegistrationViewModel(completeRegistrationState = initialState)
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(dialog = createHaveIBeenPwned()),
awaitItem(),
)
}
}
@Test
@Suppress("MaxLineLength")
fun `CreateAccountClick register returns WeakPassword should show HaveIBeenPwned dialog`() =
runTest {
mockAuthRepository.apply {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = true,
isMasterPasswordStrong = false,
)
} returns RegisterResult.WeakPassword
}
val initialState = VALID_INPUT_STATE
.copy(
passwordStrengthState = PasswordStrengthState.WEAK_1,
isCheckDataBreachesToggled = true,
)
val viewModel =
createCompleteRegistrationViewModel(completeRegistrationState = initialState)
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
dialog = createHaveIBeenPwned(
title = R.string.weak_master_password.asText(),
message = R.string.weak_password_identified_use_a_strong_password_to_protect_your_account.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = createCompleteRegistrationViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CloseClick)
assertEquals(CompleteRegistrationEvent.NavigateBack, awaitItem())
}
}
@Test
fun `On init should show toast if from email is true`() = runTest {
val viewModel = createCompleteRegistrationViewModel(
DEFAULT_STATE.copy(fromEmail = true)
)
viewModel.eventFlow.test {
assertEquals(
CompleteRegistrationEvent.ShowToast(R.string.email_verified.asText()),
awaitItem()
)
}
}
@Test
fun `ConfirmPasswordInputChange update passwordInput`() = runTest {
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(ConfirmPasswordInputChange("input"))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(confirmPasswordInput = "input"), awaitItem())
}
}
@Test
fun `PasswordHintChange update passwordInput`() = runTest {
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordHintChange("input"))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(passwordHintInput = "input"), awaitItem())
}
}
@Test
fun `PasswordInputChange update passwordInput and call getPasswordStrength`() = runTest {
coEvery {
mockAuthRepository.getPasswordStrength(EMAIL, "input")
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange("input"))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(passwordInput = "input"), awaitItem())
}
coVerify { mockAuthRepository.getPasswordStrength(EMAIL, "input") }
}
@Test
fun `CheckDataBreachesToggle should change isCheckDataBreachesToggled`() = runTest {
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(CompleteRegistrationAction.CheckDataBreachesToggle(true))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(isCheckDataBreachesToggled = true), awaitItem())
}
}
@Test
fun `ReceivePasswordStrengthResult should update password strength state`() = runTest {
val viewModel = createCompleteRegistrationViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.NONE,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_0)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_1,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_1)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_2,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_2)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_3,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_3)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.GOOD,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_4)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.STRONG,
),
awaitItem(),
)
}
}
private fun createCompleteRegistrationViewModel(
completeRegistrationState: CompleteRegistrationState? = DEFAULT_STATE,
authRepository: AuthRepository = mockAuthRepository,
): CompleteRegistrationViewModel =
CompleteRegistrationViewModel(
savedStateHandle = SavedStateHandle(mapOf("state" to completeRegistrationState)),
authRepository = authRepository,
environmentRepository = fakeEnvironmentRepository,
specialCircumstance = specialCircumstanceManager
)
companion object {
private const val PASSWORD = "longenoughtpassword"
private const val EMAIL = "test@test.com"
private const val TOKEN = "token"
private val DEFAULT_STATE = CompleteRegistrationState(
userEmail = EMAIL,
emailVerificationToken = TOKEN,
fromEmail = false,
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isCheckDataBreachesToggled = true,
dialog = null,
passwordStrengthState = PasswordStrengthState.NONE,
)
private val VALID_INPUT_STATE = CompleteRegistrationState(
userEmail = EMAIL,
emailVerificationToken = TOKEN,
fromEmail = false,
passwordInput = PASSWORD,
confirmPasswordInput = PASSWORD,
passwordHintInput = "",
isCheckDataBreachesToggled = false,
dialog = null,
passwordStrengthState = PasswordStrengthState.GOOD,
)
}
}

View File

@@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick

View File

@@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange

View File

@@ -49,6 +49,7 @@ class LandingScreenTest : BaseComposeTest() {
private var onNavigateToCreateAccountCalled = false
private var onNavigateToLoginCalled = false
private var onNavigateToEnvironmentCalled = false
private var onNavigateToStartRegistrationCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<LandingEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<LandingViewModel>(relaxed = true) {
@@ -66,6 +67,7 @@ class LandingScreenTest : BaseComposeTest() {
onNavigateToLoginCalled = true
},
onNavigateToEnvironment = { onNavigateToEnvironmentCalled = true },
onNavigateToStartRegistration = { onNavigateToStartRegistrationCalled = true },
viewModel = viewModel,
)
}

View File

@@ -305,7 +305,7 @@ class LandingViewModelTest : BaseViewModelTest() {
viewModel.eventFlow.test {
viewModel.trySendAction(LandingAction.CreateAccountClick)
assertEquals(
LandingEvent.NavigateToCreateAccount,
LandingEvent.NavigateToStartRegistration,
awaitItem(),
)
}

View File

@@ -0,0 +1,188 @@
package com.x8bit.bitwarden.ui.auth.feature.startregistration
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EmailInputChange
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class StartRegistrationScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToCompleteRegistrationCalled = false
private var onNavigateToCheckEmailCalled = false
private var onNavigateToEnvironmentCalled = false
private val intentManager = mockk<IntentManager>(relaxed = true) {
every { startCustomTabsActivity(any()) } just runs
every { startActivity(any()) } just runs
}
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<StartRegistrationEvent>()
private val viewModel = mockk<StartRegistrationViewModel>(relaxed = true) {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
every { trySendAction(any()) } just runs
}
@Before
fun setup() {
composeTestRule.setContent {
StartRegistrationScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToCompleteRegistration = { _, _ ->
onNavigateToCompleteRegistrationCalled = true
},
onNavigateToCheckEmail = { _ -> onNavigateToCheckEmailCalled = true },
onNavigateToEnvironment = { onNavigateToEnvironmentCalled = true },
intentManager = intentManager,
viewModel = viewModel,
)
}
}
@Test
fun `close click should send CloseClick action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify { viewModel.trySendAction(CloseClick) }
}
@Test
fun `NavigateBack event should invoke navigate back lambda`() {
mutableEventFlow.tryEmit(StartRegistrationEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `onNavigateToCompleteRegistration event should invoke navigate to complete registration`() {
mutableEventFlow.tryEmit(
StartRegistrationEvent.NavigateToCompleteRegistration(
email = "email",
verificationToken = "verificationToken"
)
)
assertTrue(onNavigateToCompleteRegistrationCalled)
}
@Test
fun `NavigateToCheckEmail event should invoke navigate to check email`() {
mutableEventFlow.tryEmit(
StartRegistrationEvent.NavigateToCheckEmail(
email = "email",
)
)
assertTrue(onNavigateToCheckEmailCalled)
}
@Test
fun `NavigateToEnvironment event should invoke navigate to environment`() {
mutableEventFlow.tryEmit(StartRegistrationEvent.NavigateToEnvironment)
assertTrue(onNavigateToEnvironmentCalled)
}
@Test
fun `NavigateToPrivacyPolicy event should invoke intent manager`() {
mutableEventFlow.tryEmit(StartRegistrationEvent.NavigateToPrivacyPolicy)
verify {
intentManager.launchUri("https://bitwarden.com/privacy/".toUri())
}
}
@Test
fun `NavigateToTerms event should invoke intent manager`() {
mutableEventFlow.tryEmit(StartRegistrationEvent.NavigateToTerms)
verify {
intentManager.launchUri("https://bitwarden.com/terms/".toUri())
}
}
@Test
fun `NavigateToUnsubscribe event should invoke intent manager`() {
mutableEventFlow.tryEmit(StartRegistrationEvent.NavigateToUnsubscribe)
verify {
intentManager.launchUri("https://bitwarden.com/email-preferences/".toUri())
}
}
@Test
fun `email input change should send EmailInputChange action`() {
composeTestRule.onNodeWithText("Email address").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(EmailInputChange(TEST_INPUT)) }
}
@Test
fun `name input change should send NameInputChange action`() {
composeTestRule.onNodeWithText("Name").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(StartRegistrationAction.NameInputChange(TEST_INPUT)) }
}
@Test
fun `clicking OK on the error dialog should send ErrorDialogDismiss action`() {
mutableStateFlow.update {
it.copy(
dialog = StartRegistrationDialog.Error(
BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
),
),
)
}
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(StartRegistrationAction.ErrorDialogDismiss) }
}
@Test
fun `when BasicDialogState is Shown should show dialog`() {
mutableStateFlow.update {
it.copy(
dialog = StartRegistrationDialog.Error(
BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
),
),
)
}
composeTestRule.onNode(isDialog()).assertIsDisplayed()
}
companion object {
private const val TEST_INPUT = "input"
private val DEFAULT_STATE = StartRegistrationState(
emailInput = "",
nameInput = "",
isReceiveMarketingEmailsToggled = false,
isContinueButtonEnabled = false,
selectedEnvironmentType = Environment.Type.US,
dialog = null,
)
}
}

View File

@@ -0,0 +1,398 @@
package com.x8bit.bitwarden.ui.auth.feature.startregistration
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import app.cash.turbine.turbineScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EmailInputChange
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
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
@Suppress("LargeClass")
class StartRegistrationViewModelTest : BaseViewModelTest() {
/**
* Saved state handle that has valid inputs. Useful for tests that want to test things
* after the user has entered all valid inputs.
*/
private val validInputHandle = SavedStateHandle(mapOf("state" to VALID_INPUT_STATE))
private val mockAuthRepository = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
}
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
@BeforeEach
fun setUp() {
mockkStatic(::generateUriForCaptcha)
}
@AfterEach
fun tearDown() {
unmockkStatic(::generateUriForCaptcha)
}
@Test
fun `initial state should be correct`() {
val viewModel = StartRegistrationViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should pull from saved state handle when present`() {
val savedState = StartRegistrationState(
emailInput = "",
nameInput = "",
isReceiveMarketingEmailsToggled = false,
isContinueButtonEnabled = false,
selectedEnvironmentType = Environment.Type.US,
dialog = null
)
val handle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = StartRegistrationViewModel(
savedStateHandle = handle,
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
assertEquals(savedState, viewModel.stateFlow.value)
}
@Test
fun `ContinueClick with blank email should show email required`() = runTest {
val viewModel = StartRegistrationViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
val input = "a"
viewModel.trySendAction(EmailInputChange(input))
val expectedState = DEFAULT_STATE.copy(
emailInput = input,
isContinueButtonEnabled = true,
dialog = StartRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_email.asText(),
),
),
)
viewModel.trySendAction(StartRegistrationAction.ContinueClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `ContinueClick with invalid email should show invalid email`() = runTest {
val viewModel = StartRegistrationViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
val input = " "
viewModel.trySendAction(EmailInputChange(input))
val expectedState = DEFAULT_STATE.copy(
emailInput = input,
dialog = StartRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.email_address.asText()),
),
),
)
viewModel.trySendAction(StartRegistrationAction.ContinueClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `ContinueClick with all inputs valid should show and hide loading dialog`() = runTest {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
sendVerificationEmail(
email = EMAIL,
name = NAME,
receiveMarketingEmails = true,
)
} returns SendVerificationEmailResult.Success(
emailVerificationToken = "verification_token"
)
}
val viewModel = StartRegistrationViewModel(
savedStateHandle = validInputHandle,
authRepository = repo,
environmentRepository = fakeEnvironmentRepository
)
turbineScope {
val stateFlow = viewModel.stateFlow.testIn(backgroundScope)
val eventFlow = viewModel.eventFlow.testIn(backgroundScope)
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
viewModel.trySendAction(StartRegistrationAction.ContinueClick)
assertEquals(
VALID_INPUT_STATE.copy(dialog = StartRegistrationDialog.Loading),
stateFlow.awaitItem(),
)
assertEquals(
StartRegistrationEvent.NavigateToCompleteRegistration(EMAIL, "verification_token"),
eventFlow.awaitItem(),
)
// Make sure loading dialog is hidden:
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
}
}
@Test
fun `ContinueClick register returns error should update errorDialogState`() = runTest {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
sendVerificationEmail(
email = EMAIL,
name = NAME,
receiveMarketingEmails = true,
)
} returns SendVerificationEmailResult.Error(errorMessage = "mock_error")
}
val viewModel = StartRegistrationViewModel(
savedStateHandle = validInputHandle,
authRepository = repo,
environmentRepository = fakeEnvironmentRepository
)
viewModel.stateFlow.test {
assertEquals(VALID_INPUT_STATE, awaitItem())
viewModel.trySendAction(StartRegistrationAction.ContinueClick)
assertEquals(
VALID_INPUT_STATE.copy(dialog = StartRegistrationDialog.Loading),
awaitItem(),
)
assertEquals(
VALID_INPUT_STATE.copy(
dialog = StartRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = "mock_error".asText(),
),
),
),
awaitItem(),
)
}
}
@Test
fun `ContinueClick register returns Success should emit NavigateToCheckEmail`() = runTest {
val mockkUri = mockk<Uri>()
every {
generateUriForCaptcha(captchaId = "mock_captcha_id")
} returns mockkUri
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
sendVerificationEmail(
email = EMAIL,
name = NAME,
receiveMarketingEmails = true,
)
} returns SendVerificationEmailResult.Success(emailVerificationToken = "verification_token")
}
val viewModel = StartRegistrationViewModel(
savedStateHandle = validInputHandle,
authRepository = repo,
environmentRepository = fakeEnvironmentRepository
)
viewModel.eventFlow.test {
viewModel.trySendAction(StartRegistrationAction.ContinueClick)
assertEquals(
StartRegistrationEvent.NavigateToCompleteRegistration(EMAIL, "verification_token"),
awaitItem(),
)
}
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = StartRegistrationViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
viewModel.eventFlow.test {
viewModel.trySendAction(CloseClick)
assertEquals(StartRegistrationEvent.NavigateBack, awaitItem())
}
}
@Test
fun `PrivacyPolicyClick should emit NavigatePrivacyPolicy`() = runTest {
val viewModel = StartRegistrationViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
viewModel.eventFlow.test {
viewModel.trySendAction(StartRegistrationAction.PrivacyPolicyClick)
assertEquals(StartRegistrationEvent.NavigateToPrivacyPolicy, awaitItem())
}
}
@Test
fun `TermsClick should emit NavigateToTerms`() = runTest {
val viewModel = StartRegistrationViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
viewModel.eventFlow.test {
viewModel.trySendAction(StartRegistrationAction.TermsClick)
assertEquals(StartRegistrationEvent.NavigateToTerms, awaitItem())
}
}
@Test
fun `UnsubscribeMarketingEmailsClick should emit NavigateToUnsubscribe`() = runTest {
val viewModel = StartRegistrationViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
viewModel.eventFlow.test {
viewModel.trySendAction(StartRegistrationAction.UnsubscribeMarketingEmailsClick)
assertEquals(StartRegistrationEvent.NavigateToUnsubscribe, awaitItem())
}
}
@Test
fun `EnvironmentTypeSelect should update value of selected region for US or EU`() = runTest {
val inputEnvironmentType = Environment.Type.EU
val viewModel = StartRegistrationViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
viewModel.stateFlow.test {
awaitItem()
viewModel.trySendAction(
StartRegistrationAction.EnvironmentTypeSelect(
inputEnvironmentType
)
)
assertEquals(
DEFAULT_STATE.copy(selectedEnvironmentType = Environment.Type.EU),
awaitItem(),
)
}
}
@Test
fun `EnvironmentTypeSelect should emit NavigateToEnvironment for self-hosted`() = runTest {
val inputEnvironmentType = Environment.Type.SELF_HOSTED
val viewModel = StartRegistrationViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
viewModel.eventFlow.test {
viewModel.trySendAction(
StartRegistrationAction.EnvironmentTypeSelect(
inputEnvironmentType
)
)
assertEquals(
StartRegistrationEvent.NavigateToEnvironment,
awaitItem(),
)
}
}
@Test
fun `EmailInputChange update email`() = runTest {
val viewModel = StartRegistrationViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
viewModel.trySendAction(EmailInputChange("input"))
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
emailInput = "input",
isContinueButtonEnabled = true,
), awaitItem()
)
}
}
@Test
fun `NameInputChange update name`() = runTest {
val viewModel = StartRegistrationViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
viewModel.trySendAction(StartRegistrationAction.NameInputChange("input"))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(nameInput = "input"), awaitItem())
}
}
@Test
fun `ReceiveMarketingEmailsToggle update isReceiveMarketingEmailsToggled`() = runTest {
val viewModel = StartRegistrationViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository
)
viewModel.trySendAction(StartRegistrationAction.ReceiveMarketingEmailsToggle(false))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(isReceiveMarketingEmailsToggled = false), awaitItem())
}
}
companion object {
private const val EMAIL = "test@test.com"
private const val NAME = "name"
private val DEFAULT_STATE = StartRegistrationState(
emailInput = "",
nameInput = "",
isReceiveMarketingEmailsToggled = true,
isContinueButtonEnabled = false,
selectedEnvironmentType = Environment.Type.US,
dialog = null,
)
private val VALID_INPUT_STATE = StartRegistrationState(
emailInput = EMAIL,
nameInput = NAME,
isReceiveMarketingEmailsToggled = true,
isContinueButtonEnabled = true,
selectedEnvironmentType = Environment.Type.US,
dialog = null,
)
}
}

View File

@@ -15,7 +15,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat

View File

@@ -19,7 +19,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat

View File

@@ -141,7 +141,7 @@ class VaultViewModelTest : BaseViewModelTest() {
val updatedUserId = "lockedUserId"
viewModel.trySendAction(
VaultAction.SwitchAccountClick(
accountSummary = mockk() {
accountSummary = mockk {
every { userId } returns updatedUserId
},
),
@@ -933,7 +933,7 @@ class VaultViewModelTest : BaseViewModelTest() {
val updatedUserId = "lockedUserId"
viewModel.trySendAction(
VaultAction.SwitchAccountClick(
accountSummary = mockk() {
accountSummary = mockk {
every { userId } returns updatedUserId
},
),