mirror of
https://github.com/bitwarden/android.git
synced 2026-05-10 16:45:43 -05:00
Compare commits
40 Commits
release/20
...
pm-9933/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
155d107ce4 | ||
|
|
3251d776a2 | ||
|
|
c5df4c115b | ||
|
|
fd1ecbb759 | ||
|
|
8484d111a6 | ||
|
|
fd8068f5f7 | ||
|
|
20b1ac2a79 | ||
|
|
61f7daddf3 | ||
|
|
2cbede24b5 | ||
|
|
cde639de8c | ||
|
|
5a225fb786 | ||
|
|
64af6eac55 | ||
|
|
78031e306f | ||
|
|
45159f2e1b | ||
|
|
1ee216cb7d | ||
|
|
0c878956d2 | ||
|
|
e819043c9e | ||
|
|
99c496e300 | ||
|
|
6c78ecf297 | ||
|
|
fbbb3379ca | ||
|
|
54d232e3b0 | ||
|
|
bcaeb82623 | ||
|
|
fa94904028 | ||
|
|
be5bc73e5b | ||
|
|
b95f456e11 | ||
|
|
3b93014b70 | ||
|
|
ca3bbb54b7 | ||
|
|
e5dded89ef | ||
|
|
de2b374619 | ||
|
|
68eb8b716c | ||
|
|
75c2f0e97e | ||
|
|
ec8c1e36b6 | ||
|
|
12db5ee610 | ||
|
|
51f9e3f24c | ||
|
|
37822f48f5 | ||
|
|
066a352095 | ||
|
|
363998a624 | ||
|
|
9094f6f1d0 | ||
|
|
6f7b6fea6d | ||
|
|
6994ced5d2 |
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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?>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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?>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
|
||||
84
app/src/main/res/drawable/email_check.xml
Normal file
84
app/src/main/res/drawable/email_check.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ class LandingViewModelTest : BaseViewModelTest() {
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(LandingAction.CreateAccountClick)
|
||||
assertEquals(
|
||||
LandingEvent.NavigateToCreateAccount,
|
||||
LandingEvent.NavigateToStartRegistration,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user