mirror of
https://github.com/bitwarden/android.git
synced 2026-05-13 15:21:11 -05:00
Compare commits
42 Commits
v2024.8.0
...
pm-6702/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d05f5f758 | ||
|
|
bd55b9ce72 | ||
|
|
4726cb743a | ||
|
|
244d259804 | ||
|
|
eab94dde79 | ||
|
|
2bb921b592 | ||
|
|
18b58e75f8 | ||
|
|
e2cd3867dd | ||
|
|
524b9e9a08 | ||
|
|
4b35484abb | ||
|
|
d305dc3081 | ||
|
|
dde90a251a | ||
|
|
516cd72f66 | ||
|
|
63884e8518 | ||
|
|
8a4d436f1f | ||
|
|
ab279e2264 | ||
|
|
2876d75a21 | ||
|
|
aaa0ce4ecd | ||
|
|
499bc20850 | ||
|
|
2bed4986a1 | ||
|
|
151b081161 | ||
|
|
e3371b7620 | ||
|
|
551f948644 | ||
|
|
4bd81782c8 | ||
|
|
4dbcec85bb | ||
|
|
5a0b1caecd | ||
|
|
2b13151bd1 | ||
|
|
5e643e11fd | ||
|
|
2789b1cc37 | ||
|
|
b7a47eb91e | ||
|
|
06f6f19255 | ||
|
|
e717183239 | ||
|
|
edb87202d2 | ||
|
|
9b808058f5 | ||
|
|
89589aa907 | ||
|
|
805fea630c | ||
|
|
145f8adf0c | ||
|
|
6bb5ef7417 | ||
|
|
722726882b | ||
|
|
9ed30d7913 | ||
|
|
6c5c0c7c03 | ||
|
|
a57a7e099c |
@@ -1 +0,0 @@
|
||||
shroud.reportKover 'App', 'app/build/reports/kover/reportStandardDebug.xml', 80, 80, false
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
## Compatibility
|
||||
|
||||
- **Minimum SDK**: 28
|
||||
- **Minimum SDK**: 29
|
||||
- **Target SDK**: 34
|
||||
- **Device Types Supported**: Phone and Tablet
|
||||
- **Orientations Supported**: Portrait and Landscape
|
||||
|
||||
@@ -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>
|
||||
<intent-filter>
|
||||
<action android:name="com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY" />
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -33,7 +32,4 @@ class BitwardenApplication : Application() {
|
||||
|
||||
@Inject
|
||||
lateinit var restrictionManager: RestrictionManager
|
||||
|
||||
@Inject
|
||||
lateinit var serverConfigRepository: ServerConfigRepository
|
||||
}
|
||||
|
||||
@@ -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.getFido2AssertionRequestOrNull
|
||||
@@ -34,6 +35,7 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Clock
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
|
||||
@@ -53,6 +55,7 @@ class MainViewModel @Inject constructor(
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val clock: Clock,
|
||||
) : BaseViewModel<MainState, MainEvent, MainAction>(
|
||||
initialState = MainState(
|
||||
theme = settingsRepository.appTheme,
|
||||
@@ -188,6 +191,7 @@ class MainViewModel @Inject constructor(
|
||||
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
|
||||
val hasVaultShortcut = intent.isMyVaultShortcut
|
||||
val fido2CredentialRequestData = intent.getFido2CredentialRequestOrNull()
|
||||
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
|
||||
val fido2CredentialAssertionRequest = intent.getFido2AssertionRequestOrNull()
|
||||
val fido2GetCredentialsRequest = intent.getFido2GetCredentialsRequestOrNull()
|
||||
when {
|
||||
@@ -201,6 +205,17 @@ class MainViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
completeRegistrationData != null -> {
|
||||
if (authRepository.activeUserId != null) {
|
||||
authRepository.hasPendingAccountAddition = true
|
||||
}
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.CompleteRegistration(
|
||||
completeRegistrationData = completeRegistrationData,
|
||||
timestamp = clock.millis(),
|
||||
)
|
||||
}
|
||||
|
||||
autofillSaveItem != null -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.AutofillSave(
|
||||
|
||||
@@ -45,12 +45,22 @@ interface AuthDiskSource {
|
||||
*/
|
||||
fun clearData(userId: String)
|
||||
|
||||
/**
|
||||
* Retrieves the state indicating that the user should use a key connector.
|
||||
*/
|
||||
fun getShouldUseKeyConnector(userId: String): Boolean?
|
||||
|
||||
/**
|
||||
* Stores the boolean indicating that the user should use a key connector.
|
||||
*/
|
||||
fun storeShouldUseKeyConnector(userId: String, shouldUseKeyConnector: Boolean?)
|
||||
|
||||
/**
|
||||
* Retrieves the state indicating that the user has chosen to trust this device.
|
||||
*
|
||||
* Note: This indicates intent to trust the device, the device may not be trusted yet.
|
||||
*/
|
||||
fun getShouldTrustDevice(userId: String): Boolean
|
||||
fun getShouldTrustDevice(userId: String): Boolean?
|
||||
|
||||
/**
|
||||
* Stores the boolean indicating that the user has chosen to trust this device for the given
|
||||
|
||||
@@ -39,6 +39,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 USES_KEY_CONNECTOR = "usesKeyConnector"
|
||||
|
||||
/**
|
||||
* Primary implementation of [AuthDiskSource].
|
||||
@@ -122,15 +123,26 @@ class AuthDiskSourceImpl(
|
||||
storeMasterPasswordHash(userId = userId, passwordHash = null)
|
||||
storePolicies(userId = userId, policies = null)
|
||||
storeAccountTokens(userId = userId, accountTokens = null)
|
||||
storeShouldUseKeyConnector(userId = userId, shouldUseKeyConnector = null)
|
||||
|
||||
// Do not remove the DeviceKey or PendingAuthRequest on logout, these are persisted
|
||||
// indefinitely unless the TDE flow explicitly removes them.
|
||||
}
|
||||
|
||||
override fun getShouldTrustDevice(userId: String): Boolean =
|
||||
requireNotNull(
|
||||
getBoolean(key = SHOULD_TRUST_DEVICE_KEY.appendIdentifier(userId), default = false),
|
||||
override fun getShouldUseKeyConnector(
|
||||
userId: String,
|
||||
): Boolean? = getBoolean(key = USES_KEY_CONNECTOR.appendIdentifier(userId))
|
||||
|
||||
override fun storeShouldUseKeyConnector(userId: String, shouldUseKeyConnector: Boolean?) {
|
||||
putBoolean(
|
||||
key = USES_KEY_CONNECTOR.appendIdentifier(userId),
|
||||
value = shouldUseKeyConnector,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getShouldTrustDevice(
|
||||
userId: String,
|
||||
): Boolean? = getBoolean(key = SHOULD_TRUST_DEVICE_KEY.appendIdentifier(userId))
|
||||
|
||||
override fun storeShouldTrustDevice(userId: String, shouldTrustDevice: Boolean?) {
|
||||
putBoolean(SHOULD_TRUST_DEVICE_KEY.appendIdentifier(userId), shouldTrustDevice)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.api
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.CreateAccountKeysRequest
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyOtpRequestJson
|
||||
@@ -13,6 +14,13 @@ import retrofit2.http.POST
|
||||
* Defines raw calls under the /accounts API with authentication applied.
|
||||
*/
|
||||
interface AuthenticatedAccountsApi {
|
||||
|
||||
/**
|
||||
* Converts the currently active account to a key-connector account.
|
||||
*/
|
||||
@POST("/accounts/convert-to-key-connector")
|
||||
suspend fun convertToKeyConnector(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Creates the keys for the current account.
|
||||
*/
|
||||
@@ -45,6 +53,12 @@ interface AuthenticatedAccountsApi {
|
||||
@HTTP(method = "POST", path = "/accounts/password", hasBody = true)
|
||||
suspend fun resetPassword(@Body body: ResetPasswordRequestJson): Result<Unit>
|
||||
|
||||
/**
|
||||
* Sets the key connector key.
|
||||
*/
|
||||
@POST("/accounts/set-key-connector-key")
|
||||
suspend fun setKeyConnectorKey(@Body body: KeyConnectorKeyRequestJson): Result<Unit>
|
||||
|
||||
/**
|
||||
* Sets the password.
|
||||
*/
|
||||
|
||||
@@ -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 kotlinx.serialization.json.JsonPrimitive
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.Field
|
||||
@@ -66,4 +69,14 @@ 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<JsonPrimitive?>
|
||||
}
|
||||
|
||||
@@ -73,10 +73,8 @@ object AuthNetworkModule {
|
||||
fun providesHaveIBeenPwnedService(
|
||||
retrofits: Retrofits,
|
||||
): HaveIBeenPwnedService = HaveIBeenPwnedServiceImpl(
|
||||
retrofits
|
||||
.staticRetrofitBuilder
|
||||
.baseUrl("https://api.pwnedpasswords.com")
|
||||
.build()
|
||||
api = retrofits
|
||||
.createStaticRetrofit(baseUrl = "https://api.pwnedpasswords.com")
|
||||
.create(),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents the request body used to create the key connector keys for an account.
|
||||
*/
|
||||
@Serializable
|
||||
data class KeyConnectorKeyRequestJson(
|
||||
@SerialName("key") val userKey: String,
|
||||
@SerialName("keys") val keys: Keys,
|
||||
@SerialName("kdf") val kdfType: KdfTypeJson,
|
||||
@SerialName("kdfIterations") val kdfIterations: Int?,
|
||||
@SerialName("kdfMemory") val kdfMemory: Int?,
|
||||
@SerialName("kdfParallelism") val kdfParallelism: Int?,
|
||||
@SerialName("orgIdentifier") val organizationIdentifier: String,
|
||||
) {
|
||||
/**
|
||||
* 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,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,23 @@
|
||||
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,47 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The response body for sending a verification email.
|
||||
*/
|
||||
@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()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.network.service
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
|
||||
@@ -11,6 +12,11 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequest
|
||||
*/
|
||||
interface AccountsService {
|
||||
|
||||
/**
|
||||
* Converts the currently active account to a key-connector account.
|
||||
*/
|
||||
suspend fun convertToKeyConnector(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Creates a new account's keys.
|
||||
*/
|
||||
@@ -49,6 +55,11 @@ interface AccountsService {
|
||||
*/
|
||||
suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit>
|
||||
|
||||
/**
|
||||
* Set the key connector key.
|
||||
*/
|
||||
suspend fun setKeyConnectorKey(body: KeyConnectorKeyRequestJson): Result<Unit>
|
||||
|
||||
/**
|
||||
* Set the password.
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedAccount
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.CreateAccountKeysRequest
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
|
||||
@@ -21,6 +22,12 @@ class AccountsServiceImpl(
|
||||
private val json: Json,
|
||||
) : AccountsService {
|
||||
|
||||
/**
|
||||
* Converts the currently active account to a key-connector account.
|
||||
*/
|
||||
override suspend fun convertToKeyConnector(): Result<Unit> =
|
||||
authenticatedAccountsApi.convertToKeyConnector()
|
||||
|
||||
override suspend fun createAccountKeys(
|
||||
publicKey: String,
|
||||
encryptedPrivateKey: String,
|
||||
@@ -93,6 +100,10 @@ class AccountsServiceImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setKeyConnectorKey(
|
||||
body: KeyConnectorKeyRequestJson,
|
||||
): Result<Unit> = authenticatedAccountsApi.setKeyConnectorKey(body)
|
||||
|
||||
override suspend fun setPassword(
|
||||
body: SetPasswordRequestJson,
|
||||
): Result<Unit> = authenticatedAccountsApi.setPassword(body)
|
||||
|
||||
@@ -5,8 +5,10 @@ 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
|
||||
|
||||
/**
|
||||
@@ -58,4 +60,16 @@ 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<String?>
|
||||
|
||||
/**
|
||||
* Register a new account to Bitwarden using email verification flow.
|
||||
*/
|
||||
suspend fun registerFinish(body: RegisterFinishRequestJson): Result<RegisterResponseJson>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -32,16 +34,21 @@ class IdentityServiceImpl(
|
||||
.register(body)
|
||||
.recoverCatching { throwable ->
|
||||
val bitwardenError = throwable.toBitwardenError()
|
||||
bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.CaptchaRequired>(
|
||||
code = 400,
|
||||
json = json,
|
||||
) ?: bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Invalid>(
|
||||
codes = listOf(400, 429),
|
||||
json = json,
|
||||
) ?: bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Error>(
|
||||
code = 429,
|
||||
json = json,
|
||||
) ?: throw throwable
|
||||
bitwardenError
|
||||
.parseErrorBodyOrNull<RegisterResponseJson.CaptchaRequired>(
|
||||
code = 400,
|
||||
json = json,
|
||||
)
|
||||
?: bitwardenError
|
||||
.parseErrorBodyOrNull<RegisterResponseJson.Invalid>(
|
||||
codes = listOf(400, 429),
|
||||
json = json,
|
||||
)
|
||||
?: bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Error>(
|
||||
code = 429,
|
||||
json = json,
|
||||
)
|
||||
?: throw throwable
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@@ -101,4 +108,32 @@ class IdentityServiceImpl(
|
||||
refreshToken = refreshToken,
|
||||
)
|
||||
.executeForResult()
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
override suspend fun sendVerificationEmail(
|
||||
body: SendVerificationEmailRequestJson,
|
||||
): Result<String?> {
|
||||
return api
|
||||
.sendVerificationEmail(body = body)
|
||||
.map { it?.content }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import com.bitwarden.core.RegisterKeyResponse
|
||||
import com.bitwarden.core.RegisterTdeKeyResponse
|
||||
import com.bitwarden.crypto.HashPurpose
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.sdk.Client
|
||||
import com.bitwarden.sdk.ClientAuth
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toPasswordStrengthOrNull
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toUByte
|
||||
import com.x8bit.bitwarden.data.platform.datasource.sdk.BaseSdkSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
||||
|
||||
/**
|
||||
@@ -19,12 +19,13 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
||||
* [ClientAuth].
|
||||
*/
|
||||
class AuthSdkSourceImpl(
|
||||
private val sdkClientManager: SdkClientManager,
|
||||
) : AuthSdkSource {
|
||||
sdkClientManager: SdkClientManager,
|
||||
) : BaseSdkSource(sdkClientManager = sdkClientManager),
|
||||
AuthSdkSource {
|
||||
|
||||
override suspend fun getNewAuthRequest(
|
||||
email: String,
|
||||
): Result<AuthRequestResponse> = runCatching {
|
||||
): Result<AuthRequestResponse> = runCatchingWithLogs {
|
||||
getClient()
|
||||
.auth()
|
||||
.newAuthRequest(
|
||||
@@ -35,7 +36,7 @@ class AuthSdkSourceImpl(
|
||||
override suspend fun getUserFingerprint(
|
||||
email: String,
|
||||
publicKey: String,
|
||||
): Result<String> = runCatching {
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient()
|
||||
.platform()
|
||||
.fingerprint(
|
||||
@@ -51,7 +52,7 @@ class AuthSdkSourceImpl(
|
||||
password: String,
|
||||
kdf: Kdf,
|
||||
purpose: HashPurpose,
|
||||
): Result<String> = runCatching {
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient()
|
||||
.auth()
|
||||
.hashPassword(
|
||||
@@ -66,7 +67,7 @@ class AuthSdkSourceImpl(
|
||||
email: String,
|
||||
password: String,
|
||||
kdf: Kdf,
|
||||
): Result<RegisterKeyResponse> = runCatching {
|
||||
): Result<RegisterKeyResponse> = runCatchingWithLogs {
|
||||
getClient()
|
||||
.auth()
|
||||
.makeRegisterKeys(
|
||||
@@ -81,7 +82,7 @@ class AuthSdkSourceImpl(
|
||||
email: String,
|
||||
orgPublicKey: String,
|
||||
rememberDevice: Boolean,
|
||||
): Result<RegisterTdeKeyResponse> = runCatching {
|
||||
): Result<RegisterTdeKeyResponse> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.auth()
|
||||
.makeRegisterTdeKeys(
|
||||
@@ -95,7 +96,7 @@ class AuthSdkSourceImpl(
|
||||
email: String,
|
||||
password: String,
|
||||
additionalInputs: List<String>,
|
||||
): Result<PasswordStrength> = runCatching {
|
||||
): Result<PasswordStrength> = runCatchingWithLogs {
|
||||
@Suppress("UnsafeCallOnNullableType")
|
||||
getClient()
|
||||
.auth()
|
||||
@@ -111,7 +112,7 @@ class AuthSdkSourceImpl(
|
||||
password: String,
|
||||
passwordStrength: PasswordStrength,
|
||||
policy: MasterPasswordPolicyOptions,
|
||||
): Result<Boolean> = runCatching {
|
||||
): Result<Boolean> = runCatchingWithLogs {
|
||||
getClient()
|
||||
.auth()
|
||||
.satisfiesPolicy(
|
||||
@@ -120,8 +121,4 @@ class AuthSdkSourceImpl(
|
||||
policy = policy,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getClient(
|
||||
userId: String? = null,
|
||||
): Client = sdkClientManager.getOrCreateClient(userId = userId)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class TrustedDeviceManagerImpl(
|
||||
private val devicesService: DevicesService,
|
||||
) : TrustedDeviceManager {
|
||||
override suspend fun trustThisDeviceIfNecessary(userId: String): Result<Boolean> =
|
||||
if (!authDiskSource.getShouldTrustDevice(userId = userId)) {
|
||||
if (authDiskSource.getShouldTrustDevice(userId = userId) != true) {
|
||||
false.asSuccess()
|
||||
} else {
|
||||
vaultSdkSource
|
||||
|
||||
@@ -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
|
||||
@@ -253,6 +254,7 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
|
||||
email: String,
|
||||
masterPassword: String,
|
||||
masterPasswordHint: String?,
|
||||
emailVerificationToken: String? = null,
|
||||
captchaToken: String?,
|
||||
shouldCheckDataBreaches: Boolean,
|
||||
isMasterPasswordStrong: Boolean,
|
||||
@@ -354,4 +356,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
|
||||
}
|
||||
|
||||
@@ -16,10 +16,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
|
||||
@@ -50,6 +52,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
|
||||
@@ -467,7 +470,8 @@ class AuthRepositoryImpl(
|
||||
userId = userId,
|
||||
email = account.profile.email,
|
||||
orgPublicKey = organizationKeys.publicKey,
|
||||
rememberDevice = authDiskSource.getShouldTrustDevice(userId = userId),
|
||||
rememberDevice = authDiskSource
|
||||
.getShouldTrustDevice(userId = userId) == true,
|
||||
)
|
||||
}
|
||||
.flatMap { keys ->
|
||||
@@ -723,6 +727,7 @@ class AuthRepositoryImpl(
|
||||
email: String,
|
||||
masterPassword: String,
|
||||
masterPasswordHint: String?,
|
||||
emailVerificationToken: String?,
|
||||
captchaToken: String?,
|
||||
shouldCheckDataBreaches: Boolean,
|
||||
isMasterPasswordStrong: Boolean,
|
||||
@@ -751,21 +756,40 @@ 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 = {
|
||||
@@ -1159,6 +1183,28 @@ 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)
|
||||
},
|
||||
onFailure = {
|
||||
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?,
|
||||
) : 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,24 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.autofill.builder
|
||||
|
||||
import android.service.autofill.FillRequest
|
||||
import android.service.autofill.SaveInfo
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
|
||||
|
||||
/**
|
||||
@@ -12,13 +11,11 @@ interface SaveInfoBuilder {
|
||||
/**
|
||||
* Build a save info out the provided data. If that isn't possible, return null.
|
||||
*
|
||||
* @param autofillAppInfo App data that is required for building the [SaveInfo].
|
||||
* @param autofillPartition The portion of the processed [FillRequest] that will be filled.
|
||||
* @param fillRequest The [FillRequest] that initiated the autofill flow.
|
||||
* @param packageName The package name that was extracted from the [FillRequest].
|
||||
*/
|
||||
fun build(
|
||||
autofillAppInfo: AutofillAppInfo,
|
||||
autofillPartition: AutofillPartition,
|
||||
fillRequest: FillRequest,
|
||||
packageName: String?,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.autofill.builder
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.service.autofill.FillRequest
|
||||
import android.service.autofill.SaveInfo
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
|
||||
@@ -16,9 +13,7 @@ class SaveInfoBuilderImpl(
|
||||
val settingsRepository: SettingsRepository,
|
||||
) : SaveInfoBuilder {
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
override fun build(
|
||||
autofillAppInfo: AutofillAppInfo,
|
||||
autofillPartition: AutofillPartition,
|
||||
fillRequest: FillRequest,
|
||||
packageName: String?,
|
||||
@@ -29,12 +24,8 @@ class SaveInfoBuilderImpl(
|
||||
|
||||
// Docs state that password fields cannot be reliably saved
|
||||
// in Compat mode since they show as masked values.
|
||||
val isInCompatMode = if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.Q) {
|
||||
// Attempt to automatically establish compat request mode on Android 10+
|
||||
(fillRequest.flags or FillRequest.FLAG_COMPATIBILITY_MODE_REQUEST) == fillRequest.flags
|
||||
} else {
|
||||
COMPAT_BROWSERS.contains(packageName)
|
||||
}
|
||||
val isInCompatMode = (fillRequest.flags or
|
||||
FillRequest.FLAG_COMPATIBILITY_MODE_REQUEST) == fillRequest.flags
|
||||
|
||||
// If login and compat mode, the password might be obfuscated,
|
||||
// in which case we should skip the save request.
|
||||
@@ -58,103 +49,3 @@ class SaveInfoBuilderImpl(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* These browsers function using the compatibility shim for the Autofill Framework.
|
||||
*
|
||||
* Ensure that these entries are sorted alphabetically and keep this list synchronized with the
|
||||
* values in /xml/autofill_service_configuration.xml and
|
||||
* /xml-v30/autofill_service_configuration.xml.
|
||||
*/
|
||||
private val COMPAT_BROWSERS: List<String> = listOf(
|
||||
"alook.browser",
|
||||
"alook.browser.google",
|
||||
"app.vanadium.browser",
|
||||
"com.amazon.cloud9",
|
||||
"com.android.browser",
|
||||
"com.android.chrome",
|
||||
"com.android.htmlviewer",
|
||||
"com.avast.android.secure.browser",
|
||||
"com.avg.android.secure.browser",
|
||||
"com.brave.browser",
|
||||
"com.brave.browser_beta",
|
||||
"com.brave.browser_default",
|
||||
"com.brave.browser_dev",
|
||||
"com.brave.browser_nightly",
|
||||
"com.chrome.beta",
|
||||
"com.chrome.canary",
|
||||
"com.chrome.dev",
|
||||
"com.cookiegames.smartcookie",
|
||||
"com.cookiejarapps.android.smartcookieweb",
|
||||
"com.ecosia.android",
|
||||
"com.google.android.apps.chrome",
|
||||
"com.google.android.apps.chrome_dev",
|
||||
"com.google.android.captiveportallogin",
|
||||
"com.iode.firefox",
|
||||
"com.jamal2367.styx",
|
||||
"com.kiwibrowser.browser",
|
||||
"com.kiwibrowser.browser.dev",
|
||||
"com.lemurbrowser.exts",
|
||||
"com.microsoft.emmx",
|
||||
"com.microsoft.emmx.beta",
|
||||
"com.microsoft.emmx.canary",
|
||||
"com.microsoft.emmx.dev",
|
||||
"com.mmbox.browser",
|
||||
"com.mmbox.xbrowser",
|
||||
"com.mycompany.app.soulbrowser",
|
||||
"com.naver.whale",
|
||||
"com.neeva.app",
|
||||
"com.opera.browser",
|
||||
"com.opera.browser.beta",
|
||||
"com.opera.gx",
|
||||
"com.opera.mini.native",
|
||||
"com.opera.mini.native.beta",
|
||||
"com.opera.touch",
|
||||
"com.qflair.browserq",
|
||||
"com.qwant.liberty",
|
||||
"com.rainsee.create",
|
||||
"com.sec.android.app.sbrowser",
|
||||
"com.sec.android.app.sbrowser.beta",
|
||||
"com.stoutner.privacybrowser.free",
|
||||
"com.stoutner.privacybrowser.standard",
|
||||
"com.vivaldi.browser",
|
||||
"com.vivaldi.browser.snapshot",
|
||||
"com.vivaldi.browser.sopranos",
|
||||
"com.yandex.browser",
|
||||
"com.yjllq.internet",
|
||||
"com.yjllq.kito",
|
||||
"com.yujian.ResideMenuDemo",
|
||||
"com.z28j.feel",
|
||||
"idm.internet.download.manager",
|
||||
"idm.internet.download.manager.adm.lite",
|
||||
"idm.internet.download.manager.plus",
|
||||
"io.github.forkmaintainers.iceraven",
|
||||
"mark.via",
|
||||
"mark.via.gp",
|
||||
"net.dezor.browser",
|
||||
"net.slions.fulguris.full.download",
|
||||
"net.slions.fulguris.full.download.debug",
|
||||
"net.slions.fulguris.full.playstore",
|
||||
"net.slions.fulguris.full.playstore.debug",
|
||||
"org.adblockplus.browser",
|
||||
"org.adblockplus.browser.beta",
|
||||
"org.bromite.bromite",
|
||||
"org.bromite.chromium",
|
||||
"org.chromium.chrome",
|
||||
"org.codeaurora.swe.browser",
|
||||
"org.cromite.cromite",
|
||||
"org.gnu.icecat",
|
||||
"org.mozilla.fenix",
|
||||
"org.mozilla.fenix.nightly",
|
||||
"org.mozilla.fennec_aurora",
|
||||
"org.mozilla.fennec_fdroid",
|
||||
"org.mozilla.firefox",
|
||||
"org.mozilla.firefox_beta",
|
||||
"org.mozilla.reference.browser",
|
||||
"org.mozilla.rocket",
|
||||
"org.torproject.torbrowser",
|
||||
"org.torproject.torbrowser_alpha",
|
||||
"org.ungoogled.chromium.extensions.stable",
|
||||
"org.ungoogled.chromium.stable",
|
||||
"us.spotco.fennec_dos",
|
||||
)
|
||||
|
||||
@@ -24,10 +24,7 @@ object Fido2NetworkModule {
|
||||
): DigitalAssetLinkService =
|
||||
DigitalAssetLinkServiceImpl(
|
||||
digitalAssetLinkApi = retrofits
|
||||
.staticRetrofitBuilder
|
||||
// This URL will be overridden dynamically.
|
||||
.baseUrl("https://www.bitwarden.com")
|
||||
.build()
|
||||
.createStaticRetrofit()
|
||||
.create(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,7 +128,6 @@ class AutofillProcessorImpl(
|
||||
autofillRequest = autofillRequest,
|
||||
)
|
||||
val saveInfo = saveInfoBuilder.build(
|
||||
autofillAppInfo = autofillAppInfo,
|
||||
autofillPartition = autofillRequest.partition,
|
||||
fillRequest = fillRequest,
|
||||
packageName = autofillRequest.packageName,
|
||||
|
||||
@@ -11,18 +11,15 @@ abstract class BaseDiskSource(
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
) {
|
||||
/**
|
||||
* Gets the [Boolean] for the given [key] from [SharedPreferences], or return the [default]
|
||||
* value if that key is not present.
|
||||
* Gets the [Boolean] for the given [key] from [SharedPreferences], or returns `null` if that
|
||||
* key is not present.
|
||||
*/
|
||||
protected fun getBoolean(
|
||||
key: String,
|
||||
default: Boolean? = null,
|
||||
): Boolean? =
|
||||
protected fun getBoolean(key: String): Boolean? =
|
||||
if (sharedPreferences.contains(key.withBase())) {
|
||||
sharedPreferences.getBoolean(key.withBase(), false)
|
||||
} else {
|
||||
// Make sure we can return a null value as a default if necessary
|
||||
default
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,18 +39,15 @@ abstract class BaseDiskSource(
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the [Int] for the given [key] from [SharedPreferences], or return the [default] value
|
||||
* if that key is not present.
|
||||
* Gets the [Int] for the given [key] from [SharedPreferences], or returns `null` if that key
|
||||
* is not present.
|
||||
*/
|
||||
protected fun getInt(
|
||||
key: String,
|
||||
default: Int? = null,
|
||||
): Int? =
|
||||
protected fun getInt(key: String): Int? =
|
||||
if (sharedPreferences.contains(key.withBase())) {
|
||||
sharedPreferences.getInt(key.withBase(), 0)
|
||||
} else {
|
||||
// Make sure we can return a null value as a default if necessary
|
||||
default
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,18 +67,15 @@ abstract class BaseDiskSource(
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the [Long] for the given [key] from [SharedPreferences], or return the [default] value
|
||||
* if that key is not present.
|
||||
* Gets the [Long] for the given [key] from [SharedPreferences], or returns `null` if that key
|
||||
* is not present.
|
||||
*/
|
||||
protected fun getLong(
|
||||
key: String,
|
||||
default: Long? = null,
|
||||
): Long? =
|
||||
protected fun getLong(key: String): Long? =
|
||||
if (sharedPreferences.contains(key.withBase())) {
|
||||
sharedPreferences.getLong(key.withBase(), 0)
|
||||
} else {
|
||||
// Make sure we can return a null value as a default if necessary
|
||||
default
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,8 +96,7 @@ abstract class BaseDiskSource(
|
||||
|
||||
protected fun getString(
|
||||
key: String,
|
||||
default: String? = null,
|
||||
): String? = sharedPreferences.getString(key.withBase(), default)
|
||||
): String? = sharedPreferences.getString(key.withBase(), null)
|
||||
|
||||
protected fun putString(
|
||||
key: String,
|
||||
|
||||
@@ -17,4 +17,14 @@ interface EnvironmentDiskSource {
|
||||
* if any.
|
||||
*/
|
||||
val preAuthEnvironmentUrlDataFlow: Flow<EnvironmentUrlDataJson?>
|
||||
|
||||
/**
|
||||
* Gets the pre authentication urls for the given [userEmail].
|
||||
*/
|
||||
fun getPreAuthEnvironmentUrlDataForEmail(userEmail: String): EnvironmentUrlDataJson?
|
||||
|
||||
/**
|
||||
* Stores the [urls] for the given [userEmail].
|
||||
*/
|
||||
fun storePreAuthEnvironmentUrlDataForEmail(userEmail: String, urls: EnvironmentUrlDataJson)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private const val PRE_AUTH_URLS_KEY = "preAuthEnvironmentUrls"
|
||||
private const val EMAIL_VERIFICATION_URLS = "emailVerificationUrls"
|
||||
|
||||
/**
|
||||
* Primary implementation of [EnvironmentDiskSource].
|
||||
@@ -35,4 +36,22 @@ class EnvironmentDiskSourceImpl(
|
||||
|
||||
private val mutableEnvironmentUrlDataFlow =
|
||||
bufferedMutableSharedFlow<EnvironmentUrlDataJson?>(replay = 1)
|
||||
|
||||
override fun getPreAuthEnvironmentUrlDataForEmail(
|
||||
userEmail: String,
|
||||
): EnvironmentUrlDataJson? =
|
||||
getString(key = EMAIL_VERIFICATION_URLS.appendIdentifier(userEmail))
|
||||
?.let {
|
||||
json.decodeFromStringOrNull(it)
|
||||
}
|
||||
|
||||
override fun storePreAuthEnvironmentUrlDataForEmail(
|
||||
userEmail: String,
|
||||
urls: EnvironmentUrlDataJson,
|
||||
) {
|
||||
putString(
|
||||
key = EMAIL_VERIFICATION_URLS.appendIdentifier(userEmail),
|
||||
value = json.encodeToString(urls),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class PushDiskSourceImpl(
|
||||
}
|
||||
|
||||
override fun getLastPushTokenRegistrationDate(userId: String): ZonedDateTime? {
|
||||
return getLong(LAST_REGISTRATION_DATE_KEY.appendIdentifier(userId), null)
|
||||
return getLong(LAST_REGISTRATION_DATE_KEY.appendIdentifier(userId))
|
||||
?.let { getZoneDateTimeFromBinaryLong(it) }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.datasource.network.retrofit
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.http.Url
|
||||
|
||||
/**
|
||||
* A collection of various [Retrofit] instances that serve different purposes.
|
||||
@@ -36,11 +37,14 @@ interface Retrofits {
|
||||
val unauthenticatedIdentityRetrofit: Retrofit
|
||||
|
||||
/**
|
||||
* Allows access to static API calls (ex: external APIs) that do not therefore require
|
||||
* authentication with Bitwarden's servers.
|
||||
* Allows access to static API calls (ex: external APIs).
|
||||
*
|
||||
* No base URL is supplied as part of the builder and no longer is added to make this URL
|
||||
* dynamically updatable.
|
||||
* @param isAuthenticated Indicates if the [Retrofit] instance should use authentication.
|
||||
* @param baseUrl The static base url associated with this retrofit instance. This can be
|
||||
* overridden with the [Url] annotation.
|
||||
*/
|
||||
val staticRetrofitBuilder: Retrofit.Builder
|
||||
fun createStaticRetrofit(
|
||||
isAuthenticated: Boolean = false,
|
||||
baseUrl: String = "https://api.bitwarden.com",
|
||||
): Retrofit
|
||||
}
|
||||
|
||||
@@ -60,19 +60,22 @@ class RetrofitsImpl(
|
||||
|
||||
//endregion Unauthenticated Retrofits
|
||||
|
||||
//region Other Retrofits
|
||||
//region Static Retrofit
|
||||
|
||||
override val staticRetrofitBuilder: Retrofit.Builder
|
||||
get() =
|
||||
baseRetrofitBuilder
|
||||
.client(
|
||||
baseOkHttpClient
|
||||
.newBuilder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.build(),
|
||||
)
|
||||
override fun createStaticRetrofit(isAuthenticated: Boolean, baseUrl: String): Retrofit {
|
||||
val baseClient = if (isAuthenticated) authenticatedOkHttpClient else baseOkHttpClient
|
||||
return baseRetrofitBuilder
|
||||
.baseUrl(baseUrl)
|
||||
.client(
|
||||
baseClient
|
||||
.newBuilder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
//endregion Other Retrofits
|
||||
//endregion Static Retrofit
|
||||
|
||||
//region Helper properties and functions
|
||||
private val loggingInterceptor: HttpLoggingInterceptor by lazy {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.sdk
|
||||
|
||||
import android.util.Log
|
||||
import com.bitwarden.sdk.Client
|
||||
import com.x8bit.bitwarden.BuildConfig
|
||||
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
||||
|
||||
/**
|
||||
* Base class for simplifying sdk interactions.
|
||||
*/
|
||||
@Suppress("UnnecessaryAbstractClass")
|
||||
abstract class BaseSdkSource(
|
||||
protected val sdkClientManager: SdkClientManager,
|
||||
) {
|
||||
/**
|
||||
* Helper function to retrieve the [Client] associated with the given [userId].
|
||||
*/
|
||||
protected suspend fun getClient(
|
||||
userId: String? = null,
|
||||
): Client = sdkClientManager.getOrCreateClient(userId = userId)
|
||||
|
||||
/**
|
||||
* Invokes the [block] with `this` value as its receiver and returns its result if it was
|
||||
* successful and catches any exception that was thrown from the `block` and wrapping it as a
|
||||
* failure.
|
||||
*/
|
||||
protected inline fun <T, R> T.runCatchingWithLogs(
|
||||
block: T.() -> R,
|
||||
): Result<R> = runCatching(block = block)
|
||||
.onFailure {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w(this@BaseSdkSource::class.java.simpleName, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,9 @@ class FeatureFlagManagerImpl(
|
||||
|
||||
private fun <T : Any> ServerConfig?.getFlagValueOrDefault(key: FlagKey<T>): T {
|
||||
val defaultValue = key.defaultValue
|
||||
return this?.serverData
|
||||
if (!key.isRemotelyConfigured) return key.defaultValue
|
||||
return this
|
||||
?.serverData
|
||||
?.featureStates
|
||||
?.get(key.keyName)
|
||||
?.let {
|
||||
|
||||
@@ -7,17 +7,23 @@ import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthToke
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
private const val ENVIRONMENT_DEBOUNCE_TIMEOUT_MS: Long = 500L
|
||||
|
||||
/**
|
||||
* Primary implementation of [NetworkConfigManager].
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class NetworkConfigManagerImpl(
|
||||
authRepository: AuthRepository,
|
||||
private val authTokenInterceptor: AuthTokenInterceptor,
|
||||
environmentRepository: EnvironmentRepository,
|
||||
serverConfigRepository: ServerConfigRepository,
|
||||
private val baseUrlInterceptors: BaseUrlInterceptors,
|
||||
refreshAuthenticator: RefreshAuthenticator,
|
||||
dispatcherManager: DispatcherManager,
|
||||
@@ -37,11 +43,18 @@ class NetworkConfigManagerImpl(
|
||||
}
|
||||
.launchIn(collectionScope)
|
||||
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
environmentRepository
|
||||
.environmentStateFlow
|
||||
.onEach { environment ->
|
||||
baseUrlInterceptors.environment = environment
|
||||
}
|
||||
.debounce(timeoutMillis = ENVIRONMENT_DEBOUNCE_TIMEOUT_MS)
|
||||
.onEach { _ ->
|
||||
// This updates the stored service configuration by performing a network request.
|
||||
// We debounce it to avoid rapid repeated requests.
|
||||
serverConfigRepository.getServerConfig(forceRefresh = true)
|
||||
}
|
||||
.launchIn(collectionScope)
|
||||
|
||||
refreshAuthenticator.authenticatorProvider = authRepository
|
||||
|
||||
@@ -161,6 +161,7 @@ object PlatformManagerModule {
|
||||
authRepository: AuthRepository,
|
||||
authTokenInterceptor: AuthTokenInterceptor,
|
||||
environmentRepository: EnvironmentRepository,
|
||||
serverConfigRepository: ServerConfigRepository,
|
||||
baseUrlInterceptors: BaseUrlInterceptors,
|
||||
refreshAuthenticator: RefreshAuthenticator,
|
||||
dispatcherManager: DispatcherManager,
|
||||
@@ -169,6 +170,7 @@ object PlatformManagerModule {
|
||||
authRepository = authRepository,
|
||||
authTokenInterceptor = authTokenInterceptor,
|
||||
environmentRepository = environmentRepository,
|
||||
serverConfigRepository = serverConfigRepository,
|
||||
baseUrlInterceptors = baseUrlInterceptors,
|
||||
refreshAuthenticator = refreshAuthenticator,
|
||||
dispatcherManager = dispatcherManager,
|
||||
|
||||
@@ -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
|
||||
@@ -2,50 +2,75 @@ package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
/**
|
||||
* Class to hold feature flag keys.
|
||||
* @property [keyName] corresponds to the string value of a given key
|
||||
* @property [defaultValue] corresponds to default value of the flag of type [T]
|
||||
*/
|
||||
sealed class FlagKey<out T : Any> {
|
||||
/**
|
||||
* The string value of the given key. This must match the network value.
|
||||
*/
|
||||
abstract val keyName: String
|
||||
|
||||
/**
|
||||
* The value to be used if the flags value cannot be determined or is not remotely configured.
|
||||
*/
|
||||
abstract val defaultValue: T
|
||||
|
||||
/**
|
||||
* Data object holding the key for Email Verification feature
|
||||
* Indicates if the flag should respect the network value or not.
|
||||
*/
|
||||
abstract val isRemotelyConfigured: Boolean
|
||||
|
||||
/**
|
||||
* Data object holding the key for Email Verification feature.
|
||||
*/
|
||||
data object EmailVerification : FlagKey<Boolean>() {
|
||||
override val keyName: String = "email-verification"
|
||||
override val defaultValue: Boolean = false
|
||||
override val isRemotelyConfigured: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Data object holding the feature flag key for the Onboarding Carousel feature
|
||||
* Data object holding the feature flag key for the Onboarding Carousel feature.
|
||||
*/
|
||||
data object OnboardingCarousel : FlagKey<Boolean>() {
|
||||
override val keyName: String = "native-carousel-flow"
|
||||
override val defaultValue: Boolean = false
|
||||
override val isRemotelyConfigured: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Data object holding the feature flag key for the new onboarding feature
|
||||
* Data object holding the feature flag key for the new onboarding feature.
|
||||
*/
|
||||
data object OnboardingFlow : FlagKey<Boolean>() {
|
||||
override val keyName: String = "native-create-account-flow"
|
||||
override val defaultValue: Boolean = false
|
||||
override val isRemotelyConfigured: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Data object holding the key for an Int flag to be used in tests
|
||||
* Data object holding the key for a [Boolean] flag to be used in tests.
|
||||
*/
|
||||
data object DummyInt : FlagKey<Int>() {
|
||||
data object DummyBoolean : FlagKey<Boolean>() {
|
||||
override val keyName: String = "dummy-boolean"
|
||||
override val defaultValue: Boolean = false
|
||||
override val isRemotelyConfigured: Boolean = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Data object holding the key for an [Int] flag to be used in tests.
|
||||
*/
|
||||
data class DummyInt(
|
||||
override val isRemotelyConfigured: Boolean = true,
|
||||
) : FlagKey<Int>() {
|
||||
override val keyName: String = "dummy-int"
|
||||
override val defaultValue: Int = Int.MIN_VALUE
|
||||
}
|
||||
|
||||
/**
|
||||
* Data object holding the key for an String flag to be used in tests
|
||||
* Data object holding the key for a [String] flag to be used in tests.
|
||||
*/
|
||||
data object DummyString : FlagKey<String>() {
|
||||
override val keyName: String = "dummy-string"
|
||||
override val defaultValue: String = "defaultValue"
|
||||
override val isRemotelyConfigured: Boolean = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,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.
|
||||
|
||||
@@ -19,6 +19,7 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
|
||||
SpecialCircumstance.GeneratorShortcut -> null
|
||||
SpecialCircumstance.VaultShortcut -> null
|
||||
is SpecialCircumstance.Fido2Save -> null
|
||||
is SpecialCircumstance.CompleteRegistration -> null
|
||||
is SpecialCircumstance.Fido2Assertion -> null
|
||||
is SpecialCircumstance.Fido2GetCredentials -> null
|
||||
}
|
||||
@@ -35,6 +36,7 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
|
||||
SpecialCircumstance.GeneratorShortcut -> null
|
||||
SpecialCircumstance.VaultShortcut -> null
|
||||
is SpecialCircumstance.Fido2Save -> null
|
||||
is SpecialCircumstance.CompleteRegistration -> null
|
||||
is SpecialCircumstance.Fido2Assertion -> null
|
||||
is SpecialCircumstance.Fido2GetCredentials -> 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
|
||||
@@ -55,4 +56,19 @@ class EnvironmentRepositoryImpl(
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
override fun loadEnvironmentForEmail(userEmail: String): Boolean {
|
||||
val urls = environmentDiskSource
|
||||
.getPreAuthEnvironmentUrlDataForEmail(userEmail)
|
||||
?: return false
|
||||
environment = urls.toEnvironmentUrls()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun saveCurrentEnvironmentForEmail(userEmail: String) =
|
||||
environmentDiskSource
|
||||
.storePreAuthEnvironmentUrlDataForEmail(
|
||||
userEmail = userEmail,
|
||||
urls = environment.environmentUrlData,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,14 +8,14 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
*/
|
||||
interface ServerConfigRepository {
|
||||
|
||||
/**
|
||||
* Emits updates that track [ServerConfig].
|
||||
*/
|
||||
val serverConfigStateFlow: StateFlow<ServerConfig?>
|
||||
|
||||
/**
|
||||
* Gets the state [ServerConfig]. If needed or forced by [forceRefresh],
|
||||
* updates the values using server side data.
|
||||
*/
|
||||
suspend fun getServerConfig(forceRefresh: Boolean): ServerConfig?
|
||||
|
||||
/**
|
||||
* Emits updates that track [ServerConfig].
|
||||
*/
|
||||
val serverConfigStateFlow: StateFlow<ServerConfig?>
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
@@ -20,20 +18,19 @@ class ServerConfigRepositoryImpl(
|
||||
private val configDiskSource: ConfigDiskSource,
|
||||
private val configService: ConfigService,
|
||||
private val clock: Clock,
|
||||
environmentRepository: EnvironmentRepository,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : ServerConfigRepository {
|
||||
|
||||
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
|
||||
|
||||
init {
|
||||
environmentRepository
|
||||
.environmentStateFlow
|
||||
.onEach {
|
||||
getServerConfig(true)
|
||||
}
|
||||
.launchIn(unconfinedScope)
|
||||
}
|
||||
override val serverConfigStateFlow: StateFlow<ServerConfig?>
|
||||
get() = configDiskSource
|
||||
.serverConfigFlow
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = configDiskSource.serverConfig,
|
||||
)
|
||||
|
||||
override suspend fun getServerConfig(forceRefresh: Boolean): ServerConfig? {
|
||||
val localConfig = configDiskSource.serverConfig
|
||||
@@ -62,15 +59,6 @@ class ServerConfigRepositoryImpl(
|
||||
return localConfig
|
||||
}
|
||||
|
||||
override val serverConfigStateFlow: StateFlow<ServerConfig?>
|
||||
get() = configDiskSource
|
||||
.serverConfigFlow
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = configDiskSource.serverConfig,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val MINIMUM_CONFIG_SYNC_INTERVAL_SEC: Long = 60 * 60
|
||||
}
|
||||
|
||||
@@ -37,14 +37,12 @@ object PlatformRepositoryModule {
|
||||
configDiskSource: ConfigDiskSource,
|
||||
configService: ConfigService,
|
||||
clock: Clock,
|
||||
environmentRepository: EnvironmentRepository,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): ServerConfigRepository =
|
||||
ServerConfigRepositoryImpl(
|
||||
configDiskSource = configDiskSource,
|
||||
configService = configService,
|
||||
clock = clock,
|
||||
environmentRepository = environmentRepository,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,37 +3,24 @@
|
||||
package com.x8bit.bitwarden.data.platform.util
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.os.BundleCompat
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
|
||||
/**
|
||||
* A means of retrieving a [Parcelable] from an [Intent] using the given [name] in a manner that
|
||||
* is safe across SDK versions.
|
||||
*/
|
||||
inline fun <reified T> Intent.getSafeParcelableExtra(name: String): T? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getParcelableExtra(
|
||||
name,
|
||||
T::class.java,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getParcelableExtra(name)
|
||||
}
|
||||
inline fun <reified T> Intent.getSafeParcelableExtra(
|
||||
name: String,
|
||||
): T? = IntentCompat.getParcelableExtra(this, name, T::class.java)
|
||||
|
||||
/**
|
||||
* A means of retrieving a [Parcelable] from a [Bundle] using the given [name] in a manner that
|
||||
* is safe across SDK versions.
|
||||
*/
|
||||
inline fun <reified T> Bundle.getSafeParcelableExtra(name: String): T? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getParcelable(
|
||||
name,
|
||||
T::class.java,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getParcelable(name)
|
||||
}
|
||||
inline fun <reified T> Bundle.getSafeParcelableExtra(
|
||||
name: String,
|
||||
): T? = BundleCompat.getParcelable(this, name, T::class.java)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.x8bit.bitwarden.data.platform.util
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* String [Comparator] where the characters are compared giving precedence to
|
||||
* special characters.
|
||||
*/
|
||||
object SpecialCharWithPrecedenceComparator : Comparator<String> {
|
||||
override fun compare(str1: String, str2: String): Int {
|
||||
val minLength = minOf(str1.length, str2.length)
|
||||
for (i in 0 until minLength) {
|
||||
val char1 = str1[i]
|
||||
val char2 = str2[i]
|
||||
val compareResult = compareCharsSpecialCharsWithPrecedence(char1, char2)
|
||||
if (compareResult != 0) {
|
||||
return compareResult
|
||||
}
|
||||
}
|
||||
// If all compared chars are the same give precedence to the shorter String.
|
||||
return str1.length - str2.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two characters, where a special character is considered with higher precedence over
|
||||
* letters and numbers. If both characters are a letter and they are equal ignoring the case,
|
||||
* give priority to the lowercase instance. If they are both a digit or a non-equal letter
|
||||
* use the default [String.compareTo] converting the chars to the [Locale] specific uppercase
|
||||
* String.
|
||||
*/
|
||||
private fun compareCharsSpecialCharsWithPrecedence(c1: Char, c2: Char): Int {
|
||||
return when {
|
||||
c1.isLetterOrDigit() && !c2.isLetterOrDigit() -> 1
|
||||
!c1.isLetterOrDigit() && c2.isLetterOrDigit() -> -1
|
||||
c1.isLetter() && c2.isLetter() && c1.equals(other = c2, ignoreCase = true) -> {
|
||||
compareLettersLowerCaseFirst(c1 = c1, c2 = c2)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val upperCaseStr1 = c1.toString().uppercase(Locale.getDefault())
|
||||
val upperCaseStr2 = c2.toString().uppercase(Locale.getDefault())
|
||||
upperCaseStr1.compareTo(upperCaseStr2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two equal letters ignoring case (i.e. 'A' == 'a'), give precedence to the
|
||||
* the character which is lowercase. If both [c1] and [c2] are equal and the
|
||||
* same case return 0 to indicate they are the same.
|
||||
*/
|
||||
private fun compareLettersLowerCaseFirst(c1: Char, c2: Char): Int {
|
||||
require(
|
||||
value = c1.isLetter() &&
|
||||
c2.isLetter() &&
|
||||
c1.equals(other = c2, ignoreCase = true),
|
||||
) {
|
||||
"Both character must be the same letter, case does not matter."
|
||||
}
|
||||
|
||||
return when {
|
||||
!c1.isLowerCase() && c2.isLowerCase() -> 1
|
||||
c1.isLowerCase() && !c2.isLowerCase() -> -1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package com.x8bit.bitwarden.data.platform.util
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Compare two characters, where a special character is considered with higher precedence over
|
||||
* letters and numbers. If both characters are a letter or a digit use the default
|
||||
* [Char.compareTo].
|
||||
*/
|
||||
private fun compareCharsSpecialCharsWithPrecedence(c1: Char, c2: Char): Int {
|
||||
return when {
|
||||
c1.isLetterOrDigit() && !c2.isLetterOrDigit() -> 1
|
||||
!c1.isLetterOrDigit() && c2.isLetterOrDigit() -> -1
|
||||
else -> c1.compareTo(c2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* String [Comparator] where the characters are compared giving precedence to
|
||||
* special characters.
|
||||
*/
|
||||
object CompareStringSpecialCharWithPrecedence : Comparator<String> {
|
||||
override fun compare(str1: String, str2: String): Int {
|
||||
val uppercaseStr1 = str1.uppercase(Locale.getDefault())
|
||||
val uppercaseStr2 = str2.uppercase(Locale.getDefault())
|
||||
val minLength = minOf(uppercaseStr1.length, uppercaseStr2.length)
|
||||
for (i in 0 until minLength) {
|
||||
val char1 = uppercaseStr1[i]
|
||||
val char2 = uppercaseStr2[i]
|
||||
val compareResult = compareCharsSpecialCharsWithPrecedence(char1, char2)
|
||||
if (compareResult != 0) {
|
||||
return compareResult
|
||||
}
|
||||
}
|
||||
// If all compared chars are the same give precedence to the shorter String.
|
||||
return uppercaseStr1.length - uppercaseStr2.length
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package com.x8bit.bitwarden.data.tools.generator.datasource.sdk
|
||||
import com.bitwarden.generators.PassphraseGeneratorRequest
|
||||
import com.bitwarden.generators.PasswordGeneratorRequest
|
||||
import com.bitwarden.generators.UsernameGeneratorRequest
|
||||
import com.bitwarden.sdk.Client
|
||||
import com.bitwarden.sdk.ClientGenerators
|
||||
import com.x8bit.bitwarden.data.platform.datasource.sdk.BaseSdkSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
||||
|
||||
/**
|
||||
@@ -14,46 +14,43 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
||||
* [ClientGenerators] provided by the Bitwarden SDK.
|
||||
*/
|
||||
class GeneratorSdkSourceImpl(
|
||||
private val sdkClientManager: SdkClientManager,
|
||||
) : GeneratorSdkSource {
|
||||
sdkClientManager: SdkClientManager,
|
||||
) : BaseSdkSource(sdkClientManager = sdkClientManager),
|
||||
GeneratorSdkSource {
|
||||
|
||||
override suspend fun generatePassword(
|
||||
request: PasswordGeneratorRequest,
|
||||
): Result<String> = runCatching {
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient().generators().password(request)
|
||||
}
|
||||
|
||||
override suspend fun generatePassphrase(
|
||||
request: PassphraseGeneratorRequest,
|
||||
): Result<String> = runCatching {
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient().generators().passphrase(request)
|
||||
}
|
||||
|
||||
override suspend fun generatePlusAddressedEmail(
|
||||
request: UsernameGeneratorRequest.Subaddress,
|
||||
): Result<String> = runCatching {
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient().generators().username(request)
|
||||
}
|
||||
|
||||
override suspend fun generateCatchAllEmail(
|
||||
request: UsernameGeneratorRequest.Catchall,
|
||||
): Result<String> = runCatching {
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient().generators().username(request)
|
||||
}
|
||||
|
||||
override suspend fun generateRandomWord(
|
||||
request: UsernameGeneratorRequest.Word,
|
||||
): Result<String> = runCatching {
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient().generators().username(request)
|
||||
}
|
||||
|
||||
override suspend fun generateForwardedServiceEmail(
|
||||
request: UsernameGeneratorRequest.Forwarded,
|
||||
): Result<String> = runCatching {
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient().generators().username(request)
|
||||
}
|
||||
|
||||
private suspend fun getClient(
|
||||
userId: String? = null,
|
||||
): Client = sdkClientManager.getOrCreateClient(userId = userId)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.x8bit.bitwarden.data.tools.generator.repository.model
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.BaseEnumeratedIntSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A data class representing the configuration options for both password and passphrase generation.
|
||||
*
|
||||
* @property type The type of passcode to be generated, as defined in PasscodeType.
|
||||
* @property length The total length of the generated password.
|
||||
* @property allowAmbiguousChar Indicates whether ambiguous characters are allowed in the password.
|
||||
* @property hasNumbers Indicates whether the password should contain numbers.
|
||||
@@ -23,6 +26,8 @@ import kotlinx.serialization.Serializable
|
||||
*/
|
||||
@Serializable
|
||||
data class PasscodeGenerationOptions(
|
||||
@SerialName("type")
|
||||
val type: PasscodeType,
|
||||
|
||||
// Password-specific options
|
||||
|
||||
@@ -69,4 +74,22 @@ data class PasscodeGenerationOptions(
|
||||
|
||||
@SerialName("includeNumber")
|
||||
val allowIncludeNumber: Boolean,
|
||||
)
|
||||
) {
|
||||
/**
|
||||
* Represents different Passcode types.
|
||||
*/
|
||||
@Serializable(with = PasscodeTypeSerializer::class)
|
||||
enum class PasscodeType {
|
||||
@SerialName("0")
|
||||
PASSWORD,
|
||||
|
||||
@SerialName("1")
|
||||
PASSPHRASE,
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
private class PasscodeTypeSerializer :
|
||||
BaseEnumeratedIntSerializer<PasscodeGenerationOptions.PasscodeType>(
|
||||
PasscodeGenerationOptions.PasscodeType.entries.toTypedArray(),
|
||||
)
|
||||
|
||||
@@ -35,10 +35,7 @@ object VaultNetworkModule {
|
||||
clock: Clock,
|
||||
): CiphersService = CiphersServiceImpl(
|
||||
azureApi = retrofits
|
||||
.staticRetrofitBuilder
|
||||
// This URL will be overridden dynamically
|
||||
.baseUrl("https://www.bitwarden.com")
|
||||
.build()
|
||||
.createStaticRetrofit()
|
||||
.create(),
|
||||
ciphersApi = retrofits.authenticatedApiRetrofit.create(),
|
||||
json = json,
|
||||
@@ -63,10 +60,7 @@ object VaultNetworkModule {
|
||||
clock: Clock,
|
||||
): SendsService = SendsServiceImpl(
|
||||
azureApi = retrofits
|
||||
.staticRetrofitBuilder
|
||||
// This URL will be overridden dynamically
|
||||
.baseUrl("https://www.bitwarden.com")
|
||||
.build()
|
||||
.createStaticRetrofit()
|
||||
.create(),
|
||||
sendsApi = retrofits.authenticatedApiRetrofit.create(),
|
||||
json = json,
|
||||
@@ -87,10 +81,7 @@ object VaultNetworkModule {
|
||||
retrofits: Retrofits,
|
||||
): DownloadService = DownloadServiceImpl(
|
||||
downloadApi = retrofits
|
||||
.staticRetrofitBuilder
|
||||
// This URL will be overridden dynamically
|
||||
.baseUrl("https://www.bitwarden.com")
|
||||
.build()
|
||||
.createStaticRetrofit()
|
||||
.create(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import com.bitwarden.fido.Fido2CredentialAutofillView
|
||||
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse
|
||||
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAttestationResponse
|
||||
import com.bitwarden.sdk.BitwardenException
|
||||
import com.bitwarden.sdk.Client
|
||||
import com.bitwarden.sdk.ClientVault
|
||||
import com.bitwarden.sdk.Fido2CredentialStore
|
||||
import com.bitwarden.send.Send
|
||||
@@ -28,6 +27,7 @@ import com.bitwarden.vault.FolderView
|
||||
import com.bitwarden.vault.PasswordHistory
|
||||
import com.bitwarden.vault.PasswordHistoryView
|
||||
import com.bitwarden.vault.TotpResponse
|
||||
import com.x8bit.bitwarden.data.platform.datasource.sdk.BaseSdkSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest
|
||||
@@ -50,16 +50,18 @@ import java.io.File
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class VaultSdkSourceImpl(
|
||||
private val sdkClientManager: SdkClientManager,
|
||||
sdkClientManager: SdkClientManager,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : VaultSdkSource {
|
||||
) : BaseSdkSource(sdkClientManager = sdkClientManager),
|
||||
VaultSdkSource {
|
||||
|
||||
override fun clearCrypto(userId: String) {
|
||||
sdkClientManager.destroyClient(userId = userId)
|
||||
}
|
||||
|
||||
override suspend fun getTrustDevice(
|
||||
userId: String,
|
||||
): Result<TrustDeviceResponse> = runCatching {
|
||||
): Result<TrustDeviceResponse> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.auth()
|
||||
.trustDevice()
|
||||
@@ -69,7 +71,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
pin: String,
|
||||
): Result<DerivePinKeyResponse> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.crypto()
|
||||
.derivePinKey(pin = pin)
|
||||
@@ -79,7 +81,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
encryptedPin: String,
|
||||
): Result<String> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.crypto()
|
||||
.derivePinUserKey(encryptedPin = encryptedPin)
|
||||
@@ -89,7 +91,7 @@ class VaultSdkSourceImpl(
|
||||
publicKey: String,
|
||||
userId: String,
|
||||
): Result<String> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.auth()
|
||||
.approveAuthRequest(publicKey = publicKey)
|
||||
@@ -98,7 +100,7 @@ class VaultSdkSourceImpl(
|
||||
override suspend fun getResetPasswordKey(
|
||||
orgPublicKey: String,
|
||||
userId: String,
|
||||
): Result<String> = runCatching {
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.crypto()
|
||||
.enrollAdminPasswordReset(publicKey = orgPublicKey)
|
||||
@@ -107,7 +109,7 @@ class VaultSdkSourceImpl(
|
||||
override suspend fun getUserEncryptionKey(
|
||||
userId: String,
|
||||
): Result<String> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.crypto()
|
||||
.getUserEncryptionKey()
|
||||
@@ -116,7 +118,7 @@ class VaultSdkSourceImpl(
|
||||
override suspend fun getUserFingerprint(
|
||||
userId: String,
|
||||
): Result<String> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.platform()
|
||||
.userFingerprint(fingerprintMaterial = userId)
|
||||
@@ -126,7 +128,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
request: InitUserCryptoRequest,
|
||||
): Result<InitializeCryptoResult> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
try {
|
||||
getClient(userId = userId)
|
||||
.crypto()
|
||||
@@ -142,7 +144,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
request: InitOrgCryptoRequest,
|
||||
): Result<InitializeCryptoResult> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
try {
|
||||
getClient(userId = userId)
|
||||
.crypto()
|
||||
@@ -160,7 +162,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
sendView: SendView,
|
||||
): Result<Send> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.sends()
|
||||
.encrypt(send = sendView)
|
||||
@@ -171,7 +173,7 @@ class VaultSdkSourceImpl(
|
||||
send: Send,
|
||||
fileBuffer: ByteArray,
|
||||
): Result<ByteArray> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.sends()
|
||||
.encryptBuffer(
|
||||
@@ -186,7 +188,7 @@ class VaultSdkSourceImpl(
|
||||
path: String,
|
||||
destinationFilePath: String,
|
||||
): Result<File> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.sends()
|
||||
.encryptFile(
|
||||
@@ -204,7 +206,7 @@ class VaultSdkSourceImpl(
|
||||
decryptedFilePath: String,
|
||||
encryptedFilePath: String,
|
||||
): Result<Attachment> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.attachments()
|
||||
@@ -220,7 +222,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
cipherView: CipherView,
|
||||
): Result<Cipher> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.ciphers()
|
||||
@@ -231,7 +233,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
cipher: Cipher,
|
||||
): Result<CipherView> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.ciphers()
|
||||
@@ -242,7 +244,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
cipherList: List<Cipher>,
|
||||
): Result<List<CipherListView>> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.ciphers()
|
||||
@@ -253,7 +255,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
cipherList: List<Cipher>,
|
||||
): Result<List<CipherView>> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
val ciphers = getClient(userId = userId).vault().ciphers()
|
||||
withContext(context = dispatcherManager.default) {
|
||||
cipherList.map { async { ciphers.decrypt(cipher = it) } }.awaitAll()
|
||||
@@ -264,7 +266,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
collection: Collection,
|
||||
): Result<CollectionView> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.collections()
|
||||
@@ -275,7 +277,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
collectionList: List<Collection>,
|
||||
): Result<List<CollectionView>> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.collections()
|
||||
@@ -286,7 +288,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
send: Send,
|
||||
): Result<SendView> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.sends()
|
||||
.decrypt(send = send)
|
||||
@@ -296,7 +298,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
sendList: List<Send>,
|
||||
): Result<List<SendView>> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
val sends = getClient(userId = userId).sends()
|
||||
withContext(dispatcherManager.default) {
|
||||
sendList.map { async { sends.decrypt(send = it) } }.awaitAll()
|
||||
@@ -307,7 +309,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
folder: FolderView,
|
||||
): Result<Folder> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.folders()
|
||||
@@ -318,7 +320,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
folder: Folder,
|
||||
): Result<FolderView> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.folders()
|
||||
@@ -329,7 +331,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
folderList: List<Folder>,
|
||||
): Result<List<FolderView>> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.folders()
|
||||
@@ -343,7 +345,7 @@ class VaultSdkSourceImpl(
|
||||
encryptedFilePath: String,
|
||||
decryptedFilePath: String,
|
||||
): Result<Unit> =
|
||||
runCatching {
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.attachments()
|
||||
@@ -358,7 +360,7 @@ class VaultSdkSourceImpl(
|
||||
override suspend fun encryptPasswordHistory(
|
||||
userId: String,
|
||||
passwordHistory: PasswordHistoryView,
|
||||
): Result<PasswordHistory> = runCatching {
|
||||
): Result<PasswordHistory> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.passwordHistory()
|
||||
@@ -368,7 +370,7 @@ class VaultSdkSourceImpl(
|
||||
override suspend fun decryptPasswordHistoryList(
|
||||
userId: String,
|
||||
passwordHistoryList: List<PasswordHistory>,
|
||||
): Result<List<PasswordHistoryView>> = runCatching {
|
||||
): Result<List<PasswordHistoryView>> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.passwordHistory()
|
||||
@@ -379,7 +381,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
totp: String,
|
||||
time: DateTime,
|
||||
): Result<TotpResponse> = runCatching {
|
||||
): Result<TotpResponse> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.generateTotp(
|
||||
@@ -392,7 +394,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
organizationId: String,
|
||||
cipherView: CipherView,
|
||||
): Result<CipherView> = runCatching {
|
||||
): Result<CipherView> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.ciphers()
|
||||
@@ -403,7 +405,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
password: String,
|
||||
passwordHash: String,
|
||||
): Result<Boolean> = runCatching {
|
||||
): Result<Boolean> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.auth()
|
||||
.validatePassword(
|
||||
@@ -416,7 +418,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
password: String,
|
||||
encryptedUserKey: String,
|
||||
): Result<String> = runCatching {
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.auth()
|
||||
.validatePasswordUserKey(
|
||||
@@ -428,7 +430,7 @@ class VaultSdkSourceImpl(
|
||||
override suspend fun updatePassword(
|
||||
userId: String,
|
||||
newPassword: String,
|
||||
): Result<UpdatePasswordResponse> = runCatching {
|
||||
): Result<UpdatePasswordResponse> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.crypto()
|
||||
.updatePassword(newPassword = newPassword)
|
||||
@@ -439,7 +441,7 @@ class VaultSdkSourceImpl(
|
||||
folders: List<Folder>,
|
||||
ciphers: List<Cipher>,
|
||||
format: ExportFormat,
|
||||
): Result<String> = runCatching {
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.exporters()
|
||||
.exportVault(
|
||||
@@ -452,7 +454,7 @@ class VaultSdkSourceImpl(
|
||||
override suspend fun registerFido2Credential(
|
||||
request: RegisterFido2CredentialRequest,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
): Result<PublicKeyCredentialAuthenticatorAttestationResponse> = runCatching {
|
||||
): Result<PublicKeyCredentialAuthenticatorAttestationResponse> = runCatchingWithLogs {
|
||||
callbackFlow {
|
||||
try {
|
||||
val client = getClient(request.userId)
|
||||
@@ -483,11 +485,10 @@ class VaultSdkSourceImpl(
|
||||
.first()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
override suspend fun authenticateFido2Credential(
|
||||
request: AuthenticateFido2CredentialRequest,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
): Result<PublicKeyCredentialAuthenticatorAssertionResponse> = runCatching {
|
||||
): Result<PublicKeyCredentialAuthenticatorAssertionResponse> = runCatchingWithLogs {
|
||||
callbackFlow {
|
||||
try {
|
||||
val client = getClient(request.userId)
|
||||
@@ -521,7 +522,7 @@ class VaultSdkSourceImpl(
|
||||
override suspend fun decryptFido2CredentialAutofillViews(
|
||||
userId: String,
|
||||
vararg cipherViews: CipherView,
|
||||
): Result<List<Fido2CredentialAutofillView>> = runCatching {
|
||||
): Result<List<Fido2CredentialAutofillView>> = runCatchingWithLogs {
|
||||
val fido2 = getClient(userId = userId).platform().fido2()
|
||||
cipherViews.flatMap { fido2.decryptFido2AutofillCredentials(cipherView = it) }
|
||||
}
|
||||
@@ -530,7 +531,7 @@ class VaultSdkSourceImpl(
|
||||
userId: String,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
relyingPartyId: String,
|
||||
): Result<List<Fido2CredentialAutofillView>> = runCatching {
|
||||
): Result<List<Fido2CredentialAutofillView>> = runCatchingWithLogs {
|
||||
getClient(userId)
|
||||
.platform()
|
||||
.fido2()
|
||||
@@ -540,8 +541,4 @@ class VaultSdkSourceImpl(
|
||||
)
|
||||
.silentlyDiscoverCredentials(relyingPartyId)
|
||||
}
|
||||
|
||||
private suspend fun getClient(
|
||||
userId: String,
|
||||
): Client = sdkClientManager.getOrCreateClient(userId = userId)
|
||||
}
|
||||
|
||||
@@ -898,27 +898,29 @@ class VaultRepositoryImpl(
|
||||
) {
|
||||
val profile = syncResponse.profile
|
||||
val userId = profile.id
|
||||
val userKey = profile.key
|
||||
val privateKey = profile.privateKey
|
||||
authDiskSource.apply {
|
||||
storeUserKey(
|
||||
userId = userId,
|
||||
userKey = userKey,
|
||||
userKey = profile.key,
|
||||
)
|
||||
storePrivateKey(
|
||||
userId = userId,
|
||||
privateKey = privateKey,
|
||||
privateKey = profile.privateKey,
|
||||
)
|
||||
storeOrganizationKeys(
|
||||
userId = profile.id,
|
||||
userId = userId,
|
||||
organizationKeys = profile.organizations
|
||||
.orEmpty()
|
||||
.filter { it.key != null }
|
||||
.associate { it.id to requireNotNull(it.key) },
|
||||
)
|
||||
storeShouldUseKeyConnector(
|
||||
userId = userId,
|
||||
shouldUseKeyConnector = profile.shouldUseKeyConnector,
|
||||
)
|
||||
storeOrganizations(
|
||||
userId = profile.id,
|
||||
organizations = syncResponse.profile.organizations,
|
||||
userId = userId,
|
||||
organizations = profile.organizations,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import com.bitwarden.vault.PasswordHistory
|
||||
import com.bitwarden.vault.SecureNote
|
||||
import com.bitwarden.vault.SecureNoteType
|
||||
import com.bitwarden.vault.UriMatchType
|
||||
import com.x8bit.bitwarden.data.platform.util.CompareStringSpecialCharWithPrecedence
|
||||
import com.x8bit.bitwarden.data.platform.util.SpecialCharWithPrecedenceComparator
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonRequest
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherRepromptTypeJson
|
||||
@@ -560,7 +560,7 @@ fun FieldTypeJson.toSdkFieldType(): FieldType =
|
||||
fun List<CipherView>.sortAlphabetically(): List<CipherView> {
|
||||
return this.sortedWith(
|
||||
comparator = { cipher1, cipher2 ->
|
||||
CompareStringSpecialCharWithPrecedence.compare(cipher1.name, cipher2.name)
|
||||
SpecialCharWithPrecedenceComparator.compare(cipher1.name, cipher2.name)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.x8bit.bitwarden.data.vault.repository.util
|
||||
|
||||
import com.bitwarden.vault.Collection
|
||||
import com.bitwarden.vault.CollectionView
|
||||
import com.x8bit.bitwarden.data.platform.util.CompareStringSpecialCharWithPrecedence
|
||||
import com.x8bit.bitwarden.data.platform.util.SpecialCharWithPrecedenceComparator
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
|
||||
|
||||
/**
|
||||
@@ -33,7 +33,7 @@ fun List<SyncResponseJson.Collection>.toEncryptedSdkCollectionList(): List<Colle
|
||||
fun List<CollectionView>.sortAlphabetically(): List<CollectionView> {
|
||||
return this.sortedWith(
|
||||
comparator = { collection1, collection2 ->
|
||||
CompareStringSpecialCharWithPrecedence.compare(collection1.name, collection2.name)
|
||||
SpecialCharWithPrecedenceComparator.compare(collection1.name, collection2.name)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.x8bit.bitwarden.data.vault.repository.util
|
||||
|
||||
import com.bitwarden.vault.Folder
|
||||
import com.bitwarden.vault.FolderView
|
||||
import com.x8bit.bitwarden.data.platform.util.CompareStringSpecialCharWithPrecedence
|
||||
import com.x8bit.bitwarden.data.platform.util.SpecialCharWithPrecedenceComparator
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.FolderJsonRequest
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
|
||||
|
||||
@@ -38,7 +38,7 @@ fun Folder.toEncryptedNetworkFolder(): FolderJsonRequest =
|
||||
fun List<FolderView>.sortAlphabetically(): List<FolderView> {
|
||||
return this.sortedWith(
|
||||
comparator = { folder1, folder2 ->
|
||||
CompareStringSpecialCharWithPrecedence.compare(folder1.name, folder2.name)
|
||||
SpecialCharWithPrecedenceComparator.compare(folder1.name, folder2.name)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import com.bitwarden.send.SendFile
|
||||
import com.bitwarden.send.SendText
|
||||
import com.bitwarden.send.SendType
|
||||
import com.bitwarden.send.SendView
|
||||
import com.x8bit.bitwarden.data.platform.util.CompareStringSpecialCharWithPrecedence
|
||||
import com.x8bit.bitwarden.data.platform.util.SpecialCharWithPrecedenceComparator
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendJsonRequest
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendTypeJson
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
|
||||
@@ -133,7 +133,7 @@ private fun SendTypeJson.toSdkSendType(): SendType =
|
||||
fun List<SendView>.sortAlphabetically(): List<SendView> {
|
||||
return this.sortedWith(
|
||||
comparator = { send1, send2 ->
|
||||
CompareStringSpecialCharWithPrecedence.compare(send1.name, send2.name)
|
||||
SpecialCharWithPrecedenceComparator.compare(send1.name, send2.name)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.accountsetup
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.composableWithPushTransitions
|
||||
|
||||
private const val SETUP_UNLOCK_ROUTE = "setup_unlock"
|
||||
|
||||
/**
|
||||
* Navigate to the setup unlock screen.
|
||||
*/
|
||||
fun NavController.navigateToSetupUnlockScreen(navOptions: NavOptions? = null) {
|
||||
this.navigate(SETUP_UNLOCK_ROUTE, navOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the setup unlock screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.setupUnlockDestination(
|
||||
onNavigateToSetupAutofill: () -> Unit,
|
||||
) {
|
||||
composableWithPushTransitions(
|
||||
route = SETUP_UNLOCK_ROUTE,
|
||||
) {
|
||||
SetupUnlockScreen(
|
||||
onNavigateToSetupAutofill = onNavigateToSetupAutofill,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.accountsetup
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.auth.feature.accountsetup.handlers.SetupUnlockHandler
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
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.scaffold.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithBiometricsSwitch
|
||||
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithPinSwitch
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
|
||||
import com.x8bit.bitwarden.ui.platform.util.isPortrait
|
||||
|
||||
/**
|
||||
* Top level composable for the setup unlock screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SetupUnlockScreen(
|
||||
onNavigateToSetupAutofill: () -> Unit,
|
||||
viewModel: SetupUnlockViewModel = hiltViewModel(),
|
||||
biometricsManager: BiometricsManager = LocalBiometricsManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val handler = remember(viewModel) { SetupUnlockHandler.create(viewModel = viewModel) }
|
||||
var showBiometricsPrompt by rememberSaveable { mutableStateOf(value = false) }
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
SetupUnlockEvent.NavigateToSetupAutofill -> onNavigateToSetupAutofill()
|
||||
is SetupUnlockEvent.ShowBiometricsPrompt -> {
|
||||
showBiometricsPrompt = true
|
||||
biometricsManager.promptBiometrics(
|
||||
onSuccess = {
|
||||
handler.unlockWithBiometricToggle()
|
||||
showBiometricsPrompt = false
|
||||
},
|
||||
onCancel = { showBiometricsPrompt = false },
|
||||
onLockOut = { showBiometricsPrompt = false },
|
||||
onError = { showBiometricsPrompt = false },
|
||||
cipher = event.cipher,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SetupUnlockScreenDialogs(dialogState = state.dialogState)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = R.string.account_setup),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = null,
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
SetupUnlockScreenContent(
|
||||
state = state,
|
||||
showBiometricsPrompt = showBiometricsPrompt,
|
||||
handler = handler,
|
||||
biometricsManager = biometricsManager,
|
||||
modifier = Modifier
|
||||
.padding(paddingValues = innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupUnlockScreenContent(
|
||||
state: SetupUnlockState,
|
||||
showBiometricsPrompt: Boolean,
|
||||
handler: SetupUnlockHandler,
|
||||
modifier: Modifier = Modifier,
|
||||
biometricsManager: BiometricsManager,
|
||||
config: Configuration = LocalConfiguration.current,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(state = rememberScrollState()),
|
||||
) {
|
||||
if (config.isPortrait) {
|
||||
SetupUnlockHeaderPortrait()
|
||||
} else {
|
||||
SetupUnlockHeaderLandscape()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
BitwardenUnlockWithBiometricsSwitch(
|
||||
isBiometricsSupported = biometricsManager.isBiometricsSupported,
|
||||
isChecked = state.isUnlockWithBiometricsEnabled || showBiometricsPrompt,
|
||||
onDisableBiometrics = handler.onDisableBiometrics,
|
||||
onEnableBiometrics = handler.onEnableBiometrics,
|
||||
modifier = Modifier
|
||||
.testTag(tag = "UnlockWithBiometricsSwitch")
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
BitwardenUnlockWithPinSwitch(
|
||||
isUnlockWithPasswordEnabled = state.isUnlockWithPasswordEnabled,
|
||||
isUnlockWithPinEnabled = state.isUnlockWithPinEnabled,
|
||||
onUnlockWithPinToggleAction = handler.onUnlockWithPinToggle,
|
||||
modifier = Modifier
|
||||
.testTag(tag = "UnlockWithPinSwitch")
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = R.string.continue_text),
|
||||
onClick = handler.onContinueClick,
|
||||
isEnabled = state.isContinueButtonEnabled,
|
||||
modifier = Modifier
|
||||
.testTag(tag = "ContinueButton")
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
SetUpLaterButton(
|
||||
onConfirmClick = handler.onSetUpLaterClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetUpLaterButton(
|
||||
modifier: Modifier,
|
||||
onConfirmClick: () -> Unit,
|
||||
) {
|
||||
var displayConfirmation by rememberSaveable { mutableStateOf(value = false) }
|
||||
if (displayConfirmation) {
|
||||
@Suppress("MaxLineLength")
|
||||
BitwardenTwoButtonDialog(
|
||||
title = stringResource(id = R.string.set_up_unlock_later),
|
||||
message = stringResource(
|
||||
id = R.string.you_can_return_to_complete_this_step_anytime_from_account_security_in_settings,
|
||||
),
|
||||
confirmButtonText = stringResource(id = R.string.confirm),
|
||||
dismissButtonText = stringResource(id = R.string.cancel),
|
||||
onConfirmClick = {
|
||||
onConfirmClick()
|
||||
displayConfirmation = false
|
||||
},
|
||||
onDismissClick = { displayConfirmation = false },
|
||||
onDismissRequest = { displayConfirmation = false },
|
||||
)
|
||||
}
|
||||
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = R.string.set_up_later),
|
||||
onClick = { displayConfirmation = true },
|
||||
modifier = modifier.testTag(tag = "SetUpLaterButton"),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.SetupUnlockHeaderPortrait() {
|
||||
Spacer(modifier = Modifier.height(height = 32.dp))
|
||||
Image(
|
||||
painter = rememberVectorPainter(id = R.drawable.account_setup),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.size(size = 100.dp)
|
||||
.align(alignment = Alignment.CenterHorizontally),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.set_up_unlock),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
@Suppress("MaxLineLength")
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.set_up_biometrics_or_choose_a_pin_code_to_quickly_access_your_vault_and_autofill_your_logins,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupUnlockHeaderLandscape(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 112.dp)
|
||||
.standardHorizontalMargin(),
|
||||
) {
|
||||
Image(
|
||||
painter = rememberVectorPainter(id = R.drawable.account_setup),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(size = 100.dp)
|
||||
.align(alignment = Alignment.CenterVertically),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(width = 24.dp))
|
||||
Column(
|
||||
modifier = Modifier.align(alignment = Alignment.CenterVertically),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.set_up_unlock),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
@Suppress("MaxLineLength")
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.set_up_biometrics_or_choose_a_pin_code_to_quickly_access_your_vault_and_autofill_your_logins,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupUnlockScreenDialogs(
|
||||
dialogState: SetupUnlockState.DialogState?,
|
||||
) {
|
||||
when (dialogState) {
|
||||
is SetupUnlockState.DialogState.Loading -> BitwardenLoadingDialog(
|
||||
visibilityState = LoadingDialogState.Shown(text = dialogState.title),
|
||||
)
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.accountsetup
|
||||
|
||||
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.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
|
||||
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.components.toggle.UnlockWithPinState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.crypto.Cipher
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* Models logic for the setup unlock screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class SetupUnlockViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val authRepository: AuthRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
) : BaseViewModel<SetupUnlockState, SetupUnlockEvent, SetupUnlockAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: run {
|
||||
val userId = requireNotNull(authRepository.userStateFlow.value).activeUserId
|
||||
val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(
|
||||
userId = userId,
|
||||
cipher = biometricsEncryptionManager.getOrCreateCipher(userId = userId),
|
||||
)
|
||||
SetupUnlockState(
|
||||
userId = userId,
|
||||
isUnlockWithPasswordEnabled = authRepository
|
||||
.userStateFlow
|
||||
.value
|
||||
?.activeAccount
|
||||
?.hasMasterPassword != false,
|
||||
isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled,
|
||||
isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled &&
|
||||
isBiometricsValid,
|
||||
dialogState = null,
|
||||
)
|
||||
},
|
||||
) {
|
||||
override fun handleAction(action: SetupUnlockAction) {
|
||||
when (action) {
|
||||
SetupUnlockAction.ContinueClick -> handleContinueClick()
|
||||
SetupUnlockAction.EnableBiometricsClick -> handleEnableBiometricsClick()
|
||||
SetupUnlockAction.SetUpLaterClick -> handleSetUpLaterClick()
|
||||
is SetupUnlockAction.UnlockWithBiometricToggle -> {
|
||||
handleUnlockWithBiometricToggle(action)
|
||||
}
|
||||
|
||||
is SetupUnlockAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action)
|
||||
is SetupUnlockAction.Internal -> handleInternalActions(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleContinueClick() {
|
||||
sendEvent(SetupUnlockEvent.NavigateToSetupAutofill)
|
||||
}
|
||||
|
||||
private fun handleEnableBiometricsClick() {
|
||||
sendEvent(
|
||||
SetupUnlockEvent.ShowBiometricsPrompt(
|
||||
// Generate a new key in case the previous one was invalidated
|
||||
cipher = biometricsEncryptionManager.createCipher(userId = state.userId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleSetUpLaterClick() {
|
||||
sendEvent(SetupUnlockEvent.NavigateToSetupAutofill)
|
||||
}
|
||||
|
||||
private fun handleUnlockWithBiometricToggle(
|
||||
action: SetupUnlockAction.UnlockWithBiometricToggle,
|
||||
) {
|
||||
if (action.isEnabled) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = SetupUnlockState.DialogState.Loading(R.string.saving.asText()),
|
||||
isUnlockWithBiometricsEnabled = true,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = settingsRepository.setupBiometricsKey()
|
||||
sendAction(SetupUnlockAction.Internal.BiometricsKeyResultReceive(result))
|
||||
}
|
||||
} else {
|
||||
settingsRepository.clearBiometricsKey()
|
||||
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUnlockWithPinToggle(action: SetupUnlockAction.UnlockWithPinToggle) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(isUnlockWithPinEnabled = action.state.isUnlockWithPinEnabled)
|
||||
}
|
||||
|
||||
when (val state = action.state) {
|
||||
UnlockWithPinState.PendingEnabled -> Unit
|
||||
UnlockWithPinState.Disabled -> settingsRepository.clearUnlockPin()
|
||||
|
||||
is UnlockWithPinState.Enabled -> {
|
||||
settingsRepository.storeUnlockPin(
|
||||
pin = state.pin,
|
||||
shouldRequireMasterPasswordOnRestart =
|
||||
state.shouldRequireMasterPasswordOnRestart,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInternalActions(action: SetupUnlockAction.Internal) {
|
||||
when (action) {
|
||||
is SetupUnlockAction.Internal.BiometricsKeyResultReceive -> {
|
||||
handleBiometricsKeyResultReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBiometricsKeyResultReceive(
|
||||
action: SetupUnlockAction.Internal.BiometricsKeyResultReceive,
|
||||
) {
|
||||
when (action.result) {
|
||||
BiometricsKeyResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = null,
|
||||
isUnlockWithBiometricsEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BiometricsKeyResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = null,
|
||||
isUnlockWithBiometricsEnabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the UI state for the setup unlock screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class SetupUnlockState(
|
||||
val userId: String,
|
||||
val isUnlockWithPasswordEnabled: Boolean,
|
||||
val isUnlockWithPinEnabled: Boolean,
|
||||
val isUnlockWithBiometricsEnabled: Boolean,
|
||||
val dialogState: DialogState?,
|
||||
) : Parcelable {
|
||||
/**
|
||||
* Indicates whether the continue button should be enabled or disabled.
|
||||
*/
|
||||
val isContinueButtonEnabled: Boolean
|
||||
get() = isUnlockWithBiometricsEnabled || isUnlockWithPinEnabled
|
||||
|
||||
/**
|
||||
* Represents the dialog UI state for the setup unlock screen.
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
/**
|
||||
* Displays a loading dialog with a title.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Loading(
|
||||
val title: Text,
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models events for the setup unlock screen.
|
||||
*/
|
||||
sealed class SetupUnlockEvent {
|
||||
/**
|
||||
* Navigate to autofill setup.
|
||||
*/
|
||||
data object NavigateToSetupAutofill : SetupUnlockEvent()
|
||||
|
||||
/**
|
||||
* Shows the prompt for biometrics using with the given [cipher].
|
||||
*/
|
||||
data class ShowBiometricsPrompt(
|
||||
val cipher: Cipher,
|
||||
) : SetupUnlockEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Models action for the setup unlock screen.
|
||||
*/
|
||||
sealed class SetupUnlockAction {
|
||||
/**
|
||||
* User toggled the unlock with biometrics switch.
|
||||
*/
|
||||
data class UnlockWithBiometricToggle(
|
||||
val isEnabled: Boolean,
|
||||
) : SetupUnlockAction()
|
||||
|
||||
/**
|
||||
* The user clicked to enable biometrics.
|
||||
*/
|
||||
data object EnableBiometricsClick : SetupUnlockAction()
|
||||
|
||||
/**
|
||||
* User toggled the unlock with pin switch.
|
||||
*/
|
||||
data class UnlockWithPinToggle(
|
||||
val state: UnlockWithPinState,
|
||||
) : SetupUnlockAction()
|
||||
|
||||
/**
|
||||
* The user clicked the continue button.
|
||||
*/
|
||||
data object ContinueClick : SetupUnlockAction()
|
||||
|
||||
/**
|
||||
* The user clicked the set up later button.
|
||||
*/
|
||||
data object SetUpLaterClick : SetupUnlockAction()
|
||||
|
||||
/**
|
||||
* Models actions that can be sent by the view model itself.
|
||||
*/
|
||||
sealed class Internal : SetupUnlockAction() {
|
||||
/**
|
||||
* A biometrics key result has been received.
|
||||
*/
|
||||
data class BiometricsKeyResultReceive(
|
||||
val result: BiometricsKeyResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.accountsetup.handlers
|
||||
|
||||
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SetupUnlockAction
|
||||
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SetupUnlockViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState
|
||||
|
||||
/**
|
||||
* A collection of handler functions for managing actions within the context of the Setup Unlock
|
||||
* Screen.
|
||||
*/
|
||||
data class SetupUnlockHandler(
|
||||
val onDisableBiometrics: () -> Unit,
|
||||
val onEnableBiometrics: () -> Unit,
|
||||
val onUnlockWithPinToggle: (UnlockWithPinState) -> Unit,
|
||||
val onContinueClick: () -> Unit,
|
||||
val onSetUpLaterClick: () -> Unit,
|
||||
val unlockWithBiometricToggle: () -> Unit,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Creates an instance of [SetupUnlockHandler] by binding actions to the provided
|
||||
* [SetupUnlockViewModel].
|
||||
*/
|
||||
fun create(viewModel: SetupUnlockViewModel): SetupUnlockHandler =
|
||||
SetupUnlockHandler(
|
||||
onDisableBiometrics = {
|
||||
viewModel.trySendAction(
|
||||
SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = false),
|
||||
)
|
||||
},
|
||||
onEnableBiometrics = {
|
||||
viewModel.trySendAction(SetupUnlockAction.EnableBiometricsClick)
|
||||
},
|
||||
onUnlockWithPinToggle = {
|
||||
viewModel.trySendAction(SetupUnlockAction.UnlockWithPinToggle(it))
|
||||
},
|
||||
onContinueClick = { viewModel.trySendAction(SetupUnlockAction.ContinueClick) },
|
||||
onSetUpLaterClick = { viewModel.trySendAction(SetupUnlockAction.SetUpLaterClick) },
|
||||
unlockWithBiometricToggle = {
|
||||
viewModel.trySendAction(
|
||||
SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = true),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,16 @@ 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.createaccount.createAccountDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount
|
||||
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.enterpriseSignOnDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.navigateToEnterpriseSignOn
|
||||
import com.x8bit.bitwarden.ui.auth.feature.environment.environmentDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.environment.navigateToEnvironment
|
||||
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator.masterPasswordGeneratorDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator.navigateToMasterPasswordGenerator
|
||||
import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE
|
||||
import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.landing.navigateToLanding
|
||||
@@ -20,10 +24,15 @@ import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin
|
||||
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.loginWithDeviceDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.model.LoginWithDeviceType
|
||||
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice
|
||||
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance.masterPasswordGuidanceDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHintDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint
|
||||
import com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout.navigateToPreventAccountLockout
|
||||
import com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout.preventAccountLockoutDestination
|
||||
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
|
||||
import com.x8bit.bitwarden.ui.auth.feature.welcome.welcomeDestination
|
||||
@@ -53,6 +62,21 @@ fun NavGraphBuilder.authGraph(
|
||||
)
|
||||
},
|
||||
)
|
||||
startRegistrationDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToCompleteRegistration = { emailAddress, verificationToken ->
|
||||
// TODO PR-3622 ADD NAVIGATION TO COMPLETE REGISTRATION
|
||||
},
|
||||
onNavigateToCheckEmail = { emailAddress ->
|
||||
navController.navigateToCheckEmail(emailAddress)
|
||||
},
|
||||
onNavigateToEnvironment = { navController.navigateToEnvironment() },
|
||||
)
|
||||
checkEmailDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateBackToLanding = {
|
||||
navController.popBackStack(route = LANDING_ROUTE, inclusive = false)
|
||||
},)
|
||||
enterpriseSignOnDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToSetPassword = { navController.navigateToSetPassword() },
|
||||
@@ -75,6 +99,7 @@ fun NavGraphBuilder.authGraph(
|
||||
onNavigateToEnvironment = {
|
||||
navController.navigateToEnvironment()
|
||||
},
|
||||
onNavigateToStartRegistration = { navController.navigateToStartRegistration() },
|
||||
)
|
||||
welcomeDestination(
|
||||
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
|
||||
@@ -123,6 +148,17 @@ fun NavGraphBuilder.authGraph(
|
||||
twoFactorLoginDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
masterPasswordGuidanceDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToGeneratePassword = { navController.navigateToMasterPasswordGenerator() },
|
||||
)
|
||||
preventAccountLockoutDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
masterPasswordGeneratorDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToPreventLockout = { navController.navigateToPreventAccountLockout() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,206 @@
|
||||
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.SpanStyle
|
||||
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.base.util.createAnnotatedString
|
||||
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
|
||||
|
||||
/**
|
||||
* Constant string to be used in string annotation tag field
|
||||
*/
|
||||
private const val TAG_URL = "URL"
|
||||
|
||||
/**
|
||||
* 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.startDefaultEmailApplication()
|
||||
}
|
||||
|
||||
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.CloseClick) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { 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))
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
val descriptionAnnotatedString = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account,
|
||||
state.email,
|
||||
),
|
||||
highlights = listOf(state.email),
|
||||
highlightStyle = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
tag = "EMAIL",
|
||||
)
|
||||
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.OpenEmailClick) }
|
||||
},
|
||||
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(
|
||||
mainString = stringResource(
|
||||
id = R.string.no_email_go_back_to_edit_your_email_address,
|
||||
),
|
||||
highlights = listOf(stringResource(id = R.string.go_back)),
|
||||
tag = TAG_URL,
|
||||
)
|
||||
ClickableText(
|
||||
text = goBackAnnotatedString,
|
||||
onClick = {
|
||||
goBackAnnotatedString
|
||||
.getStringAnnotations(TAG_URL, it, it)
|
||||
.firstOrNull()?.let {
|
||||
viewModel.trySendAction(CheckEmailAction.CloseClick)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
val logInAnnotatedString = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
id = R.string.or_log_in_you_may_already_have_an_account,
|
||||
),
|
||||
highlights = listOf(stringResource(id = R.string.log_in)),
|
||||
tag = TAG_URL,
|
||||
)
|
||||
ClickableText(
|
||||
text = logInAnnotatedString,
|
||||
onClick = {
|
||||
logInAnnotatedString
|
||||
.getStringAnnotations(TAG_URL, it, it)
|
||||
.firstOrNull()?.let {
|
||||
viewModel.trySendAction(CheckEmailAction.LoginClick)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.CloseClick -> sendEvent(CheckEmailEvent.NavigateBack)
|
||||
CheckEmailAction.LoginClick -> sendEvent(CheckEmailEvent.NavigateBackToLanding)
|
||||
CheckEmailAction.OpenEmailClick -> 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 clicked close.
|
||||
*/
|
||||
data object CloseClick : CheckEmailAction()
|
||||
|
||||
/**
|
||||
* User clicked log in.
|
||||
*/
|
||||
data object LoginClick : CheckEmailAction()
|
||||
|
||||
/**
|
||||
* User clicked open email.
|
||||
*/
|
||||
data object OpenEmailClick : CheckEmailAction()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ 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.UserState
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
@@ -34,6 +36,7 @@ class LandingViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val environmentRepository: EnvironmentRepository,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<LandingState, LandingEvent, LandingAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
@@ -191,7 +194,11 @@ class LandingViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleCreateAccountClicked() {
|
||||
sendEvent(LandingEvent.NavigateToCreateAccount)
|
||||
if (featureFlagManager.getFeatureFlag(FlagKey.EmailVerification)) {
|
||||
sendEvent(LandingEvent.NavigateToStartRegistration)
|
||||
} else {
|
||||
sendEvent(LandingEvent.NavigateToCreateAccount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDialogDismiss() {
|
||||
@@ -291,6 +298,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,32 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
|
||||
private const val MASTER_PASSWORD_GENERATOR = "master_password_generator"
|
||||
|
||||
/**
|
||||
* Navigate to master password generator screen.
|
||||
*/
|
||||
fun NavController.navigateToMasterPasswordGenerator(navOptions: NavOptions? = null) {
|
||||
this.navigate(MASTER_PASSWORD_GENERATOR, navOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the master password generator screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.masterPasswordGeneratorDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToPreventLockout: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions(
|
||||
route = MASTER_PASSWORD_GENERATOR,
|
||||
) {
|
||||
MasterPasswordGeneratorScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToPreventLockout = onNavigateToPreventLockout,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.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.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButtonWithIcon
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
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.text.BitwardenClickableText
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
|
||||
|
||||
/**
|
||||
* Top level composable for the master password generator.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun MasterPasswordGeneratorScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToPreventLockout: () -> Unit,
|
||||
viewModel: MasterPasswordGeneratorViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val resources = context.resources
|
||||
val snackbarHostState = remember {
|
||||
SnackbarHostState()
|
||||
}
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
MasterPasswordGeneratorEvent.NavigateBack -> onNavigateBack()
|
||||
MasterPasswordGeneratorEvent.NavigateToPreventLockout -> onNavigateToPreventLockout()
|
||||
is MasterPasswordGeneratorEvent.ShowSnackbar -> {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = event.text.toString(resources),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MasterPasswordGeneratorTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
onBackClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(MasterPasswordGeneratorAction.BackClickAction)
|
||||
}
|
||||
},
|
||||
onSaveClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
MasterPasswordGeneratorAction.SavePasswordClickAction,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(hostState = snackbarHostState)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
MasterPasswordGeneratorContent(
|
||||
generatedPassword = state.generatedPassword,
|
||||
onGenerateNewPassword = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
MasterPasswordGeneratorAction.GeneratePasswordClickAction,
|
||||
)
|
||||
}
|
||||
},
|
||||
onLearnToPreventLockout = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
MasterPasswordGeneratorAction.PreventLockoutClickAction,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MasterPasswordGeneratorContent(
|
||||
generatedPassword: String,
|
||||
onGenerateNewPassword: () -> Unit,
|
||||
onLearnToPreventLockout: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
BitwardenTextField(
|
||||
label = "",
|
||||
value = generatedPassword,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
shouldAddCustomLineBreaks = true,
|
||||
textStyle = LocalNonMaterialTypography.current.sensitiveInfoSmall,
|
||||
visualTransformation = nonLetterColorVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
BitwardenFilledButtonWithIcon(
|
||||
label = stringResource(R.string.generate_button_label),
|
||||
onClick = onGenerateNewPassword,
|
||||
icon = rememberVectorPainter(id = R.drawable.ic_generator),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.write_this_password_down_and_keep_it_somewhere_safe),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
BitwardenClickableText(
|
||||
label = stringResource(R.string.learn_about_other_ways_to_prevent_account_lockout),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
onClick = onLearnToPreventLockout,
|
||||
innerPadding = PaddingValues(horizontal = 0.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun MasterPasswordGeneratorTopBar(
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
onBackClick: () -> Unit,
|
||||
onSaveClick: () -> Unit,
|
||||
) {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(R.string.generate_master_password),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = R.drawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(id = R.string.back),
|
||||
onNavigationIconClick = onBackClick,
|
||||
actions = {
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = R.string.save),
|
||||
labelTextColor = MaterialTheme.colorScheme.primary,
|
||||
onClick = onSaveClick,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun MasterPasswordGeneratorTopBarPreview() {
|
||||
BitwardenTheme {
|
||||
MasterPasswordGeneratorTopBar(
|
||||
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
|
||||
onBackClick = { },
|
||||
onSaveClick = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun MasterPasswordGeneratorContentPreview() {
|
||||
BitwardenTheme {
|
||||
MasterPasswordGeneratorContent(
|
||||
generatedPassword = "really-secure-password",
|
||||
onGenerateNewPassword = { },
|
||||
onLearnToPreventLockout = { },
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.generators.PassphraseGeneratorRequest
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
|
||||
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
|
||||
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult
|
||||
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.tools.feature.generator.util.toStrictestPolicy
|
||||
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
|
||||
import kotlin.math.max
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
private const val DEFAULT_SEPARATOR = "-"
|
||||
private const val DEFAULT_WORD_COUNT = 3
|
||||
|
||||
/**
|
||||
* ViewModel to support the [MasterPasswordGeneratorScreen]
|
||||
*/
|
||||
@HiltViewModel
|
||||
@Suppress("MaxLineLength")
|
||||
class MasterPasswordGeneratorViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val generatorRepository: GeneratorRepository,
|
||||
private val policyManager: PolicyManager,
|
||||
) : BaseViewModel<MasterPasswordGeneratorState, MasterPasswordGeneratorEvent, MasterPasswordGeneratorAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: MasterPasswordGeneratorState(generatedPassword = DEFAULT_SEPARATOR),
|
||||
) {
|
||||
private var generatePasswordJob: Job? = null
|
||||
private val passphraseRequest = getPolicyBasedPassphraseRequest()
|
||||
|
||||
init {
|
||||
if (state.generatedPassword == DEFAULT_SEPARATOR) {
|
||||
generateNewPassphrase()
|
||||
}
|
||||
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: MasterPasswordGeneratorAction) {
|
||||
when (action) {
|
||||
MasterPasswordGeneratorAction.BackClickAction -> handleBackAction()
|
||||
MasterPasswordGeneratorAction.GeneratePasswordClickAction -> {
|
||||
handleGeneratePasswordAction()
|
||||
}
|
||||
|
||||
MasterPasswordGeneratorAction.PreventLockoutClickAction -> handlePreventLockoutAction()
|
||||
MasterPasswordGeneratorAction.SavePasswordClickAction -> handleSavePasswordAction()
|
||||
is MasterPasswordGeneratorAction.Internal -> {
|
||||
handleInternalAction(internalAction = action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackAction() = sendEvent(MasterPasswordGeneratorEvent.NavigateBack)
|
||||
|
||||
private fun handleSavePasswordAction() {
|
||||
// TODO [PM-10692](https://bitwarden.atlassian.net/browse/PM-10692)
|
||||
}
|
||||
|
||||
private fun handlePreventLockoutAction() =
|
||||
sendEvent(MasterPasswordGeneratorEvent.NavigateToPreventLockout)
|
||||
|
||||
private fun handleInternalAction(internalAction: MasterPasswordGeneratorAction.Internal) {
|
||||
when (internalAction) {
|
||||
is MasterPasswordGeneratorAction.Internal.ReceiveUpdatedPassphraseResultAction -> {
|
||||
handleUpdatedPassphraseResult(internalAction.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpdatedPassphraseResult(passphraseResult: GeneratedPassphraseResult) {
|
||||
when (passphraseResult) {
|
||||
GeneratedPassphraseResult.InvalidRequest -> {
|
||||
sendEvent(
|
||||
MasterPasswordGeneratorEvent.ShowSnackbar(
|
||||
R.string.an_error_has_occurred.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
is GeneratedPassphraseResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(generatedPassword = passphraseResult.generatedString)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGeneratePasswordAction() = generateNewPassphrase()
|
||||
|
||||
private fun generateNewPassphrase() {
|
||||
generatePasswordJob?.cancel()
|
||||
generatePasswordJob = viewModelScope.launch {
|
||||
val result = generatorRepository.generatePassphrase(
|
||||
passphraseGeneratorRequest = passphraseRequest,
|
||||
)
|
||||
sendAction(
|
||||
MasterPasswordGeneratorAction.Internal.ReceiveUpdatedPassphraseResultAction(
|
||||
result = result,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPolicyBasedPassphraseRequest(): PassphraseGeneratorRequest {
|
||||
val policy = policyManager
|
||||
.getActivePolicies<PolicyInformation.MasterPassword>()
|
||||
.toStrictestPolicy()
|
||||
val options = generatorRepository.getPasscodeGenerationOptions()
|
||||
val optionsWordCount = options?.numWords ?: DEFAULT_WORD_COUNT
|
||||
return PassphraseGeneratorRequest(
|
||||
numWords = max(optionsWordCount, DEFAULT_WORD_COUNT).toUByte(),
|
||||
wordSeparator = options?.wordSeparator ?: DEFAULT_SEPARATOR,
|
||||
capitalize = policy.requireUpper == true,
|
||||
includeNumber = policy.requireNumbers == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MasterPasswordGeneratorState
|
||||
*/
|
||||
@Parcelize
|
||||
data class MasterPasswordGeneratorState(
|
||||
val generatedPassword: String,
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Model events to send to the UI
|
||||
*/
|
||||
sealed class MasterPasswordGeneratorEvent {
|
||||
|
||||
/**
|
||||
* Navigate back to the previous screen.
|
||||
*/
|
||||
data object NavigateBack : MasterPasswordGeneratorEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the prevent account lockout tips screen.
|
||||
*/
|
||||
data object NavigateToPreventLockout : MasterPasswordGeneratorEvent()
|
||||
|
||||
/**
|
||||
* Show a Snackbar message.
|
||||
*/
|
||||
data class ShowSnackbar(val text: Text) : MasterPasswordGeneratorEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Model actions from the UI and internal sources for the ViewModel to handle.
|
||||
*/
|
||||
sealed class MasterPasswordGeneratorAction {
|
||||
|
||||
/**
|
||||
* Internal actions that should only be sent via the owner of the action flow.
|
||||
* @see [MasterPasswordGeneratorViewModel]
|
||||
*/
|
||||
@VisibleForTesting
|
||||
sealed class Internal : MasterPasswordGeneratorAction() {
|
||||
|
||||
/**
|
||||
* Internal action to indicate a generated password result has been received.
|
||||
*/
|
||||
data class ReceiveUpdatedPassphraseResultAction(
|
||||
val result: GeneratedPassphraseResult,
|
||||
) : Internal()
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate the generate new passphrase button has been clicked.
|
||||
*/
|
||||
data object GeneratePasswordClickAction : MasterPasswordGeneratorAction()
|
||||
|
||||
/**
|
||||
* Indicate the prevent lockout link has been clicked.
|
||||
*/
|
||||
data object PreventLockoutClickAction : MasterPasswordGeneratorAction()
|
||||
|
||||
/**
|
||||
* Indicate the back arrow button has been clicked.
|
||||
*/
|
||||
data object BackClickAction : MasterPasswordGeneratorAction()
|
||||
|
||||
/**
|
||||
* Indicate the save button has been clicked.
|
||||
*/
|
||||
data object SavePasswordClickAction : MasterPasswordGeneratorAction()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
|
||||
private const val MASTER_PASSWORD_GUIDANCE = "master_password_guidance"
|
||||
|
||||
/**
|
||||
* Navigate to the master password guidance screen.
|
||||
*/
|
||||
fun NavController.navigateToMasterPasswordGuidance(navOptions: NavOptions? = null) {
|
||||
this.navigate(MASTER_PASSWORD_GUIDANCE, navOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the master password guidance screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.masterPasswordGuidanceDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToGeneratePassword: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions(
|
||||
route = MASTER_PASSWORD_GUIDANCE,
|
||||
) {
|
||||
MasterPasswordGuidanceScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToGeneratePassword = onNavigateToGeneratePassword,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
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.scaffold.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
|
||||
private const val BULLET_TWO_TAB = "\u2022\t\t"
|
||||
|
||||
/**
|
||||
* The top level composable for the Master Password Guidance screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun MasterPasswordGuidanceScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToGeneratePassword: () -> Unit,
|
||||
viewModel: MasterPasswordGuidanceViewModel = hiltViewModel(),
|
||||
) {
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
MasterPasswordGuidanceEvent.NavigateBack -> onNavigateBack()
|
||||
MasterPasswordGuidanceEvent.NavigateToPasswordGenerator -> {
|
||||
onNavigateToGeneratePassword()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = R.string.master_password),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
|
||||
navigationIconContentDescription = stringResource(id = R.string.close),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(MasterPasswordGuidanceAction.CloseAction)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(size = 4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLowest),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 24.dp),
|
||||
) {
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.what_makes_a_password_strong),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
text = stringResource(
|
||||
R.string.the_longer_your_password_the_more_difficult_to_hack,
|
||||
),
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.the_strongest_passwords_are_usually),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
BulletTextRow(text = stringResource(R.string.twelve_or_more_characters))
|
||||
BulletTextRow(
|
||||
text = stringResource(
|
||||
R.string.random_and_complex_using_numbers_and_special_characters,
|
||||
),
|
||||
)
|
||||
BulletTextRow(
|
||||
text = stringResource(R.string.totally_different_from_your_other_passwords),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TryGeneratorCard(
|
||||
onCardClicked = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
MasterPasswordGuidanceAction.TryPasswordGeneratorAction,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TryGeneratorCard(
|
||||
onCardClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
onClick = onCardClicked,
|
||||
shape = RoundedCornerShape(size = 16.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
),
|
||||
elevation = CardDefaults.elevatedCardElevation(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = rememberVectorPainter(id = R.drawable.ic_generator),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(
|
||||
modifier = Modifier.weight(weight = 1f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.use_the_generator_to_create_a_strong_unique_password,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.try_it_out),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Icon(
|
||||
painter = rememberVectorPainter(id = R.drawable.ic_navigate_next),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BulletTextRow(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = BULLET_TWO_TAB,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun MasterPasswordGuidanceScreenPreview() {
|
||||
BitwardenTheme {
|
||||
MasterPasswordGuidanceScreen(
|
||||
onNavigateBack = {},
|
||||
onNavigateToGeneratePassword = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance
|
||||
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel for the [MasterPasswordGuidanceScreen]
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MasterPasswordGuidanceViewModel @Inject constructor() :
|
||||
BaseViewModel<Unit, MasterPasswordGuidanceEvent, MasterPasswordGuidanceAction>(
|
||||
initialState = Unit,
|
||||
) {
|
||||
|
||||
override fun handleAction(action: MasterPasswordGuidanceAction) {
|
||||
when (action) {
|
||||
MasterPasswordGuidanceAction.CloseAction -> handleCloseAction()
|
||||
MasterPasswordGuidanceAction.TryPasswordGeneratorAction -> {
|
||||
handleTryPasswordGeneratorAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTryPasswordGeneratorAction() {
|
||||
sendEvent(MasterPasswordGuidanceEvent.NavigateToPasswordGenerator)
|
||||
}
|
||||
|
||||
private fun handleCloseAction() {
|
||||
sendEvent(MasterPasswordGuidanceEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models events for the [MasterPasswordGuidanceScreen]
|
||||
*/
|
||||
sealed class MasterPasswordGuidanceEvent {
|
||||
|
||||
/**
|
||||
* Navigates back to the previous screen
|
||||
*/
|
||||
data object NavigateBack : MasterPasswordGuidanceEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the MasterPasswordGenerationScreen
|
||||
*/
|
||||
data object NavigateToPasswordGenerator : MasterPasswordGuidanceEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Models user actions on the [MasterPasswordGuidanceScreen]
|
||||
*/
|
||||
sealed class MasterPasswordGuidanceAction {
|
||||
|
||||
/**
|
||||
* User has clicked the close button
|
||||
*/
|
||||
data object CloseAction : MasterPasswordGuidanceAction()
|
||||
|
||||
/**
|
||||
* User has clicked the try generator card
|
||||
*/
|
||||
data object TryPasswordGeneratorAction : MasterPasswordGuidanceAction()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
|
||||
private const val PREVENT_ACCOUNT_LOCKOUT = "prevent_account_lockout"
|
||||
|
||||
/**
|
||||
* Navigate to prevent account lockout screen.
|
||||
*/
|
||||
fun NavController.navigateToPreventAccountLockout(navOptions: NavOptions? = null) {
|
||||
this.navigate(PREVENT_ACCOUNT_LOCKOUT, navOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the prevent account lockout screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.preventAccountLockoutDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions(
|
||||
route = PREVENT_ACCOUNT_LOCKOUT,
|
||||
) {
|
||||
PreventAccountLockoutScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
|
||||
/**
|
||||
* Top level screen component for the prevent account lockout info screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun PreventAccountLockoutScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: PreventAccountLockoutViewModel = hiltViewModel(),
|
||||
) {
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
PreventAccountLockoutEvent.NavigateBack -> onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(R.string.prevent_account_lockout),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
|
||||
navigationIconContentDescription = stringResource(id = R.string.close),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(PreventAccountLockoutAction.CloseClickAction)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
NeverLoseAccessContent()
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NeverLoseAccessContent(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(size = 4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLowest),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.never_lose_access_to_your_vault),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.the_best_way_to_make_sure_you_can_always_access_your_account,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AccountRecoveryTipRow(
|
||||
title = stringResource(R.string.create_a_hint),
|
||||
description = stringResource(
|
||||
R.string.your_hint_will_be_send_to_you_via_email_when_you_request_it,
|
||||
),
|
||||
icon = rememberVectorPainter(id = R.drawable.ic_light_bulb),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AccountRecoveryTipRow(
|
||||
title = stringResource(R.string.write_your_password_down),
|
||||
description = stringResource(R.string.keep_it_secret_keep_it_safe),
|
||||
icon = rememberVectorPainter(id = R.drawable.ic_edit),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountRecoveryTipRow(
|
||||
title: String,
|
||||
description: String,
|
||||
icon: Painter,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clearAndSetSemantics { },
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PreventAccountLockoutScreenPreview() {
|
||||
BitwardenTheme {
|
||||
PreventAccountLockoutScreen(onNavigateBack = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout
|
||||
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel for the [PreventAccountLockoutScreen].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PreventAccountLockoutViewModel @Inject constructor() :
|
||||
BaseViewModel<Unit, PreventAccountLockoutEvent, PreventAccountLockoutAction>(
|
||||
initialState = Unit,
|
||||
) {
|
||||
|
||||
override fun handleAction(action: PreventAccountLockoutAction) {
|
||||
when (action) {
|
||||
PreventAccountLockoutAction.CloseClickAction -> handleCloseClickAction()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCloseClickAction() = sendEvent(PreventAccountLockoutEvent.NavigateBack)
|
||||
}
|
||||
|
||||
/**
|
||||
* Model events to send to the [PreventAccountLockoutScreen].
|
||||
*/
|
||||
sealed class PreventAccountLockoutEvent {
|
||||
|
||||
/**
|
||||
* Navigates to the previous screen.
|
||||
*/
|
||||
data object NavigateBack : PreventAccountLockoutEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Model actions to be handled in the [PreventAccountLockoutViewModel].
|
||||
*/
|
||||
sealed class PreventAccountLockoutAction {
|
||||
|
||||
/**
|
||||
* Close button has been clicked.
|
||||
*/
|
||||
data object CloseClickAction : PreventAccountLockoutAction()
|
||||
}
|
||||
@@ -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,402 @@
|
||||
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.ContinueClick
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EmailInputChange
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EnvironmentTypeSelect
|
||||
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.ReceiveMarketingEmailsToggle
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.TermsClick
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.UnsubscribeMarketingEmailsClick
|
||||
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.base.util.createAnnotatedString
|
||||
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
|
||||
|
||||
/**
|
||||
* Constant string to be used in string annotation tag field
|
||||
*/
|
||||
private const val TAG_URL = "URL"
|
||||
|
||||
/**
|
||||
* 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(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(
|
||||
ReceiveMarketingEmailsToggle(
|
||||
it,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onUnsubscribeClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(UnsubscribeMarketingEmailsClick) }
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = R.string.continue_text),
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(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 = TAG_URL,
|
||||
annotation = strTerms,
|
||||
start = startIndexTerms,
|
||||
end = endIndexTerms,
|
||||
)
|
||||
addStringAnnotation(
|
||||
tag = 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(TAG_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,
|
||||
) {
|
||||
@Suppress("MaxLineLength")
|
||||
val annotatedLinkString = createAnnotatedString(
|
||||
mainString = stringResource(id = R.string.get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time),
|
||||
highlights = listOf(stringResource(id = R.string.unsubscribe)),
|
||||
tag = TAG_URL,
|
||||
)
|
||||
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(TAG_URL, it, it)
|
||||
.firstOrNull()?.let {
|
||||
onUnsubscribeClick()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
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.data.platform.repository.model.Environment.Type
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ContinueClick
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EmailInputChange
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.NameInputChange
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.CloseClick
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ErrorDialogDismiss
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ReceiveMarketingEmailsToggle
|
||||
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.StartRegistrationAction.UnsubscribeMarketingEmailsClick
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EnvironmentTypeSelect
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.Internal.UpdatedEnvironmentReceive
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.Internal.ReceiveSendVerificationEmailResult
|
||||
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 == 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(
|
||||
UpdatedEnvironmentReceive(environment = environment),
|
||||
)
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: StartRegistrationAction) {
|
||||
when (action) {
|
||||
is ContinueClick -> handleContinueClick()
|
||||
is EmailInputChange -> handleEmailInputChanged(action)
|
||||
is NameInputChange -> handleNameInputChanged(action)
|
||||
is CloseClick -> handleCloseClick()
|
||||
is ErrorDialogDismiss -> handleDialogDismiss()
|
||||
is ReceiveMarketingEmailsToggle -> handleReceiveMarketingEmailsToggle(
|
||||
action,
|
||||
)
|
||||
|
||||
is PrivacyPolicyClick -> handlePrivacyPolicyClick()
|
||||
is TermsClick -> handleTermsClick()
|
||||
is UnsubscribeMarketingEmailsClick -> handleUnsubscribeMarketingEmailsClick()
|
||||
is ReceiveSendVerificationEmailResult -> {
|
||||
handleReceiveSendVerificationEmailResult(action)
|
||||
}
|
||||
|
||||
is EnvironmentTypeSelect -> handleEnvironmentTypeSelect(action)
|
||||
is UpdatedEnvironmentReceive -> {
|
||||
handleUpdatedEnvironmentReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEnvironmentTypeSelect(action: EnvironmentTypeSelect) {
|
||||
val environment = when (action.environmentType) {
|
||||
Type.US -> Environment.Us
|
||||
Type.EU -> Environment.Eu
|
||||
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: 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: 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(
|
||||
ReceiveSendVerificationEmailResult(
|
||||
sendVerificationEmailResult = result,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReceiveSendVerificationEmailResult(
|
||||
result: 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: 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: 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()
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -59,6 +60,7 @@ import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalNfcManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.nfc.NfcManager
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
/**
|
||||
@@ -295,3 +297,31 @@ private fun TwoFactorLoginScreenContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
private fun TwoFactorLoginScreenContentPreview() {
|
||||
BitwardenTheme {
|
||||
BitwardenScaffold {
|
||||
TwoFactorLoginScreenContent(
|
||||
state = TwoFactorLoginState(
|
||||
TwoFactorAuthMethod.EMAIL,
|
||||
availableAuthMethods = listOf(TwoFactorAuthMethod.EMAIL),
|
||||
codeInput = "",
|
||||
dialogState = null,
|
||||
displayEmail = "email@dot.com",
|
||||
isContinueButtonEnabled = true,
|
||||
isRememberMeEnabled = true,
|
||||
captchaToken = null,
|
||||
email = "",
|
||||
password = "",
|
||||
),
|
||||
onCodeInputChange = {},
|
||||
onContinueButtonClick = {},
|
||||
onRememberMeToggle = {},
|
||||
onResendEmailButtonClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +290,8 @@ class TwoFactorLoginViewModel @Inject constructor(
|
||||
it.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.invalid_verification_code.asText(),
|
||||
message = loginResult.errorMessage?.asText()
|
||||
?: R.string.invalid_verification_code.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -23,8 +24,11 @@ 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.focus.FocusManager
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -69,6 +73,7 @@ import javax.crypto.Cipher
|
||||
fun VaultUnlockScreen(
|
||||
viewModel: VaultUnlockViewModel = hiltViewModel(),
|
||||
biometricsManager: BiometricsManager = LocalBiometricsManager.current,
|
||||
focusManager: FocusManager = LocalFocusManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
@@ -148,7 +153,12 @@ fun VaultUnlockScreen(
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { focusManager.clearFocus(force = true) },
|
||||
)
|
||||
},
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = state.vaultUnlockType.unlockScreenTitle(),
|
||||
@@ -159,7 +169,10 @@ fun VaultUnlockScreen(
|
||||
BitwardenAccountActionItem(
|
||||
initials = state.initials,
|
||||
color = state.avatarColor,
|
||||
onClick = { accountMenuVisible = !accountMenuVisible },
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
accountMenuVisible = !accountMenuVisible
|
||||
},
|
||||
)
|
||||
}
|
||||
BitwardenOverflowActionItem(
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.model.UnlockType
|
||||
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenErrorMessage
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent
|
||||
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.hexToColor
|
||||
@@ -393,7 +394,7 @@ sealed class VaultUnlockEvent {
|
||||
/**
|
||||
* Prompts the user for biometrics unlock.
|
||||
*/
|
||||
data class PromptForBiometrics(val cipher: Cipher) : VaultUnlockEvent()
|
||||
data class PromptForBiometrics(val cipher: Cipher) : BackgroundEvent, VaultUnlockEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.welcome
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -37,15 +36,23 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
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.base.util.standardHorizontalMargin
|
||||
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.scaffold.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.x8bit.bitwarden.ui.platform.util.isPortrait
|
||||
|
||||
/**
|
||||
* The custom horizontal margin that is specific to this screen.
|
||||
*/
|
||||
private val LANDSCAPE_HORIZONTAL_MARGIN: Dp = 128.dp
|
||||
|
||||
/**
|
||||
* Top level composable for the welcome screen.
|
||||
@@ -107,9 +114,6 @@ private fun WelcomeScreenContent(
|
||||
onLoginClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val horizontalPadding = if (isLandscape) 128.dp else 16.dp
|
||||
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
onPagerSwipe(pagerState.currentPage)
|
||||
}
|
||||
@@ -121,15 +125,16 @@ private fun WelcomeScreenContent(
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
HorizontalPager(state = pagerState) { index ->
|
||||
if (isLandscape) {
|
||||
WelcomeCardLandscape(
|
||||
state = state.pages[index],
|
||||
modifier = Modifier.padding(horizontal = horizontalPadding),
|
||||
)
|
||||
} else {
|
||||
if (LocalConfiguration.current.isPortrait) {
|
||||
WelcomeCardPortrait(
|
||||
state = state.pages[index],
|
||||
modifier = Modifier.padding(horizontal = horizontalPadding),
|
||||
modifier = Modifier.standardHorizontalMargin(),
|
||||
)
|
||||
} else {
|
||||
WelcomeCardLandscape(
|
||||
state = state.pages[index],
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin(landscape = LANDSCAPE_HORIZONTAL_MARGIN),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -149,7 +154,7 @@ private fun WelcomeScreenContent(
|
||||
label = stringResource(id = R.string.create_account),
|
||||
onClick = onCreateAccountClick,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = horizontalPadding)
|
||||
.standardHorizontalMargin(landscape = LANDSCAPE_HORIZONTAL_MARGIN)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
@@ -157,7 +162,7 @@ private fun WelcomeScreenContent(
|
||||
label = stringResource(id = R.string.log_in),
|
||||
onClick = onLoginClick,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = horizontalPadding)
|
||||
.standardHorizontalMargin(landscape = LANDSCAPE_HORIZONTAL_MARGIN)
|
||||
.padding(bottom = 32.dp),
|
||||
)
|
||||
|
||||
|
||||
@@ -72,9 +72,9 @@ data class WelcomeState(
|
||||
*/
|
||||
@Parcelize
|
||||
data object CardOne : WelcomeCard() {
|
||||
override val imageRes: Int = R.drawable.welcome_1
|
||||
override val titleRes: Int = R.string.privacy_prioritized
|
||||
override val messageRes: Int = R.string.welcome_message_1
|
||||
override val imageRes: Int get() = R.drawable.welcome_1
|
||||
override val titleRes: Int get() = R.string.privacy_prioritized
|
||||
override val messageRes: Int get() = R.string.welcome_message_1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,9 +82,9 @@ data class WelcomeState(
|
||||
*/
|
||||
@Parcelize
|
||||
data object CardTwo : WelcomeCard() {
|
||||
override val imageRes: Int = R.drawable.welcome_2
|
||||
override val titleRes: Int = R.string.never_guess_again
|
||||
override val messageRes: Int = R.string.welcome_message_2
|
||||
override val imageRes: Int get() = R.drawable.welcome_2
|
||||
override val titleRes: Int get() = R.string.quick_and_easy_login
|
||||
override val messageRes: Int get() = R.string.welcome_message_2
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,9 +92,9 @@ data class WelcomeState(
|
||||
*/
|
||||
@Parcelize
|
||||
data object CardThree : WelcomeCard() {
|
||||
override val imageRes: Int = R.drawable.welcome_3
|
||||
override val titleRes: Int = R.string.level_up_your_logins
|
||||
override val messageRes: Int = R.string.welcome_message_3
|
||||
override val imageRes: Int get() = R.drawable.welcome_3
|
||||
override val titleRes: Int get() = R.string.level_up_your_logins
|
||||
override val messageRes: Int get() = R.string.welcome_message_3
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,9 +102,9 @@ data class WelcomeState(
|
||||
*/
|
||||
@Parcelize
|
||||
data object CardFour : WelcomeCard() {
|
||||
override val imageRes: Int = R.drawable.welcome_4
|
||||
override val titleRes: Int = R.string.your_data_when_and_where_you_need_it
|
||||
override val messageRes: Int = R.string.welcome_message_4
|
||||
override val imageRes: Int get() = R.drawable.welcome_4
|
||||
override val titleRes: Int get() = R.string.your_data_when_and_where_you_need_it
|
||||
override val messageRes: Int get() = R.string.welcome_message_4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.x8bit.bitwarden.ui.platform.base.util
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.DividerDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -19,12 +20,14 @@ import androidx.compose.ui.input.key.isShiftPressed
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.ui.platform.util.isPortrait
|
||||
|
||||
/**
|
||||
* Adds a performance-optimized background color specified by the given [topAppBarScrollBehavior]
|
||||
@@ -120,3 +123,17 @@ fun Modifier.tabNavigation(): Modifier {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a [Modifier] extension for ensuring that the content uses the standard horizontal margin.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Stable
|
||||
@Composable
|
||||
fun Modifier.standardHorizontalMargin(
|
||||
portrait: Dp = 16.dp,
|
||||
landscape: Dp = 48.dp,
|
||||
): Modifier {
|
||||
val config = LocalConfiguration.current
|
||||
return this.padding(horizontal = if (config.isPortrait) portrait else landscape)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@ import android.content.res.Resources
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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 kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.RawValue
|
||||
|
||||
@@ -117,3 +122,48 @@ fun @receiver:StringRes Int.asText(): Text = ResText(this)
|
||||
* Convert a resource Id to [Text] with format args.
|
||||
*/
|
||||
fun @receiver:StringRes Int.asText(vararg args: Any): Text = ResArgsText(this, args.asList())
|
||||
|
||||
/**
|
||||
* Create an [AnnotatedString] with highlighted parts.
|
||||
* @param mainString the full string
|
||||
* @param highlights parts of the mainString that will be highlighted
|
||||
* @param tag the tag that will be used for the annotation
|
||||
*/
|
||||
@Composable
|
||||
fun createAnnotatedString(
|
||||
mainString: String,
|
||||
highlights: List<String>,
|
||||
highlightStyle: SpanStyle = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
tag: String,
|
||||
): AnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
append(mainString)
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
||||
),
|
||||
start = 0,
|
||||
end = mainString.length,
|
||||
)
|
||||
for (highlightString in highlights) {
|
||||
val startIndexUnsubscribe = mainString.indexOf(highlightString, ignoreCase = true)
|
||||
val endIndexUnsubscribe = startIndexUnsubscribe + highlightString.length
|
||||
addStyle(
|
||||
style = highlightStyle,
|
||||
start = startIndexUnsubscribe,
|
||||
end = endIndexUnsubscribe,
|
||||
)
|
||||
addStringAnnotation(
|
||||
tag = tag,
|
||||
annotation = highlightString,
|
||||
start = startIndexUnsubscribe,
|
||||
end = endIndexUnsubscribe,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,17 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
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.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -64,8 +69,12 @@ fun BitwardenTopAppBar(
|
||||
* - a [actions] lambda containing the set of actions (usually icons or similar) to display
|
||||
* in the app bar's trailing side. This lambda extends [RowScope], allowing flexibility in
|
||||
* defining the layout of the actions.
|
||||
* - if the title text causes an overflow in the standard material [TopAppBar] a [MediumTopAppBar]
|
||||
* will be used instead, droping the title text to a second row beneath the [navigationIcon] and
|
||||
* [actions].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun BitwardenTopAppBar(
|
||||
title: String,
|
||||
@@ -74,16 +83,12 @@ fun BitwardenTopAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.largeTopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = {
|
||||
var titleTextHasOverflow by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val navigationIconContent: @Composable () -> Unit = remember(navigationIcon) {
|
||||
{
|
||||
navigationIcon?.let {
|
||||
IconButton(
|
||||
onClick = it.onNavigationIconClick,
|
||||
@@ -96,20 +101,57 @@ fun BitwardenTopAppBar(
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.testTag("PageTitleLabel"),
|
||||
)
|
||||
},
|
||||
modifier = modifier.testTag("HeaderBarComponent"),
|
||||
actions = actions,
|
||||
}
|
||||
}
|
||||
|
||||
val topAppBarColors = TopAppBarDefaults.largeTopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (titleTextHasOverflow) {
|
||||
MediumTopAppBar(
|
||||
colors = topAppBarColors,
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = navigationIconContent,
|
||||
title = {
|
||||
// The height of the component is controlled and will only allow for 1 extra row,
|
||||
// making adding any arguments for softWrap and minLines superfluous.
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.testTag("PageTitleLabel"),
|
||||
)
|
||||
},
|
||||
modifier = modifier.testTag("HeaderBarComponent"),
|
||||
actions = actions,
|
||||
)
|
||||
} else {
|
||||
TopAppBar(
|
||||
colors = topAppBarColors,
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = navigationIconContent,
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.testTag("PageTitleLabel"),
|
||||
onTextLayout = {
|
||||
titleTextHasOverflow = it.hasVisualOverflow
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = modifier.testTag("HeaderBarComponent"),
|
||||
actions = actions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -132,6 +174,46 @@ private fun BitwardenTopAppBar_preview() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun BitwardenTopAppBarOverflow_preview() {
|
||||
BitwardenTheme {
|
||||
BitwardenTopAppBar(
|
||||
title = "Title that is too long for the top line",
|
||||
scrollBehavior = TopAppBarDefaults
|
||||
.exitUntilCollapsedScrollBehavior(
|
||||
rememberTopAppBarState(),
|
||||
),
|
||||
navigationIcon = NavigationIcon(
|
||||
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
|
||||
navigationIconContentDescription = stringResource(id = R.string.close),
|
||||
onNavigationIconClick = { },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun BitwardenTopAppBarOverflowCutoff_preview() {
|
||||
BitwardenTheme {
|
||||
BitwardenTopAppBar(
|
||||
title = "Title that is too long for the top line and the bottom line",
|
||||
scrollBehavior = TopAppBarDefaults
|
||||
.exitUntilCollapsedScrollBehavior(
|
||||
rememberTopAppBarState(),
|
||||
),
|
||||
navigationIcon = NavigationIcon(
|
||||
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
|
||||
navigationIconContentDescription = stringResource(id = R.string.close),
|
||||
onNavigationIconClick = { },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents all data required to display a [navigationIcon].
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,14 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -61,10 +64,11 @@ fun BitwardenTextField(
|
||||
shouldAddCustomLineBreaks: Boolean = false,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
isError: Boolean = false,
|
||||
autoFocus: Boolean = false,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
) {
|
||||
var widthPx by remember { mutableIntStateOf(0) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val currentTextStyle = textStyle ?: LocalTextStyle.current
|
||||
val formattedText = if (shouldAddCustomLineBreaks) {
|
||||
value.withLineBreaksAtWidth(
|
||||
@@ -78,7 +82,8 @@ fun BitwardenTextField(
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = modifier
|
||||
.onGloballyPositioned { widthPx = it.size.width },
|
||||
.onGloballyPositioned { widthPx = it.size.width }
|
||||
.focusRequester(focusRequester),
|
||||
enabled = enabled,
|
||||
label = { Text(text = label) },
|
||||
value = formattedText,
|
||||
@@ -113,6 +118,9 @@ fun BitwardenTextField(
|
||||
isError = isError,
|
||||
visualTransformation = visualTransformation,
|
||||
)
|
||||
if (autoFocus) {
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
||||
@@ -85,8 +85,10 @@ fun RootNavScreen(
|
||||
}
|
||||
|
||||
val targetRoute = when (state) {
|
||||
RootNavState.Auth -> AUTH_GRAPH_ROUTE
|
||||
RootNavState.AuthWithWelcome -> AUTH_GRAPH_ROUTE
|
||||
RootNavState.Auth,
|
||||
is RootNavState.CompleteOngoingRegistration,
|
||||
RootNavState.AuthWithWelcome,
|
||||
-> AUTH_GRAPH_ROUTE
|
||||
RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE
|
||||
RootNavState.SetPassword -> SET_PASSWORD_ROUTE
|
||||
RootNavState.Splash -> SPLASH_ROUTE
|
||||
@@ -189,6 +191,11 @@ fun RootNavScreen(
|
||||
navOptions = rootNavOptions,
|
||||
)
|
||||
}
|
||||
|
||||
is RootNavState.CompleteOngoingRegistration -> {
|
||||
navController.navigateToAuthGraph(rootNavOptions)
|
||||
// TODO PR-3622: add navigation to complete registration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,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 &&
|
||||
@@ -66,6 +67,15 @@ class RootNavViewModel @Inject constructor(
|
||||
|
||||
userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword
|
||||
|
||||
specialCircumstance is SpecialCircumstance.CompleteRegistration -> {
|
||||
RootNavState.CompleteOngoingRegistration(
|
||||
email = specialCircumstance.completeRegistrationData.email,
|
||||
verificationToken = specialCircumstance.completeRegistrationData.verificationToken,
|
||||
fromEmail = specialCircumstance.completeRegistrationData.fromEmail,
|
||||
timestamp = specialCircumstance.timestamp,
|
||||
)
|
||||
}
|
||||
|
||||
userState == null ||
|
||||
!userState.activeAccount.isLoggedIn ||
|
||||
userState.hasPendingAccountAddition -> {
|
||||
@@ -77,7 +87,7 @@ class RootNavViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
userState.activeAccount.isVaultUnlocked -> {
|
||||
when (val specialCircumstance = action.specialCircumstance) {
|
||||
when (specialCircumstance) {
|
||||
is SpecialCircumstance.AutofillSave -> {
|
||||
RootNavState.VaultUnlockedForAutofillSave(
|
||||
autofillSaveItem = specialCircumstance.autofillSaveItem,
|
||||
@@ -122,6 +132,12 @@ class RootNavViewModel @Inject constructor(
|
||||
SpecialCircumstance.VaultShortcut,
|
||||
null,
|
||||
-> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId)
|
||||
|
||||
is SpecialCircumstance.CompleteRegistration -> {
|
||||
throw IllegalStateException(
|
||||
"Special circumstance should have been already handled.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +258,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.
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ import com.bitwarden.vault.CipherView
|
||||
import com.bitwarden.vault.CollectionView
|
||||
import com.bitwarden.vault.FolderView
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.util.CompareStringSpecialCharWithPrecedence
|
||||
import com.x8bit.bitwarden.data.platform.util.SpecialCharWithPrecedenceComparator
|
||||
import com.x8bit.bitwarden.data.platform.util.subtitle
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.removeDiacritics
|
||||
@@ -369,7 +369,7 @@ private fun SendView.toDisplayItem(
|
||||
*/
|
||||
private fun List<SearchState.DisplayItem>.sortAlphabetically(): List<SearchState.DisplayItem> {
|
||||
return this.sortedWith { item1, item2 ->
|
||||
CompareStringSpecialCharWithPrecedence.compare(item1.title, item2.title)
|
||||
SpecialCharWithPrecedenceComparator.compare(item1.title, item2.title)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ fun PinInputDialog(
|
||||
BitwardenTextField(
|
||||
label = stringResource(id = R.string.pin),
|
||||
value = pin,
|
||||
autoFocus = true,
|
||||
onValueChange = { pin = it },
|
||||
keyboardType = KeyboardType.Number,
|
||||
modifier = Modifier
|
||||
|
||||
@@ -108,7 +108,7 @@ class LoginApprovalViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleCloseClicked() {
|
||||
closeScreen()
|
||||
sendClosingEvent()
|
||||
}
|
||||
|
||||
private fun handleDeclineRequestClicked() {
|
||||
@@ -138,7 +138,7 @@ class LoginApprovalViewModel @Inject constructor(
|
||||
when (action.result) {
|
||||
is AuthRequestResult.Success -> {
|
||||
sendEvent(LoginApprovalEvent.ShowToast(R.string.login_approved.asText()))
|
||||
sendEvent(LoginApprovalEvent.NavigateBack)
|
||||
sendClosingEvent()
|
||||
}
|
||||
|
||||
is AuthRequestResult.Error -> {
|
||||
@@ -184,7 +184,7 @@ class LoginApprovalViewModel @Inject constructor(
|
||||
AuthRequestUpdatesResult.Declined,
|
||||
AuthRequestUpdatesResult.Expired,
|
||||
-> {
|
||||
closeScreen()
|
||||
sendClosingEvent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,7 @@ class LoginApprovalViewModel @Inject constructor(
|
||||
when (action.result) {
|
||||
is AuthRequestResult.Success -> {
|
||||
sendEvent(LoginApprovalEvent.ShowToast(R.string.log_in_denied.asText()))
|
||||
sendEvent(LoginApprovalEvent.NavigateBack)
|
||||
sendClosingEvent()
|
||||
}
|
||||
|
||||
is AuthRequestResult.Error -> {
|
||||
@@ -206,12 +206,14 @@ class LoginApprovalViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeScreen() {
|
||||
if (state.specialCircumstance?.shouldFinishWhenComplete == true) {
|
||||
sendEvent(LoginApprovalEvent.ExitApp)
|
||||
private fun sendClosingEvent() {
|
||||
val event = if (state.specialCircumstance?.shouldFinishWhenComplete == true) {
|
||||
LoginApprovalEvent.ExitApp
|
||||
} else {
|
||||
sendEvent(LoginApprovalEvent.NavigateBack)
|
||||
LoginApprovalEvent.NavigateBack
|
||||
}
|
||||
|
||||
sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
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.UserState
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
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.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -14,25 +19,22 @@ import javax.inject.Inject
|
||||
*/
|
||||
@HiltViewModel
|
||||
class VaultUnlockedNavBarViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
authRepository: AuthRepository,
|
||||
specialCircumstancesManager: SpecialCircumstanceManager,
|
||||
) : BaseViewModel<VaultUnlockedNavBarState, VaultUnlockedNavBarEvent, VaultUnlockedNavBarAction>(
|
||||
initialState = run {
|
||||
val hasOrganization = authRepository
|
||||
.userStateFlow
|
||||
.value
|
||||
?.activeAccount
|
||||
?.organizations
|
||||
?.isNotEmpty()
|
||||
?: false
|
||||
val vaultRes = if (hasOrganization) R.string.vaults else R.string.my_vault
|
||||
VaultUnlockedNavBarState(
|
||||
vaultNavBarLabelRes = vaultRes,
|
||||
vaultNavBarContentDescriptionRes = vaultRes,
|
||||
)
|
||||
},
|
||||
initialState = VaultUnlockedNavBarState(
|
||||
vaultNavBarLabelRes = R.string.my_vault,
|
||||
vaultNavBarContentDescriptionRes = R.string.my_vault,
|
||||
),
|
||||
) {
|
||||
init {
|
||||
authRepository
|
||||
.userStateFlow
|
||||
.onEach {
|
||||
sendAction(VaultUnlockedNavBarAction.Internal.UserStateUpdateReceive(it))
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
when (specialCircumstancesManager.specialCircumstance) {
|
||||
SpecialCircumstance.GeneratorShortcut -> {
|
||||
sendEvent(VaultUnlockedNavBarEvent.NavigateToGeneratorScreen)
|
||||
@@ -54,6 +56,15 @@ class VaultUnlockedNavBarViewModel @Inject constructor(
|
||||
VaultUnlockedNavBarAction.SendTabClick -> handleSendTabClicked()
|
||||
VaultUnlockedNavBarAction.SettingsTabClick -> handleSettingsTabClicked()
|
||||
VaultUnlockedNavBarAction.VaultTabClick -> handleVaultTabClicked()
|
||||
is VaultUnlockedNavBarAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: VaultUnlockedNavBarAction.Internal) {
|
||||
when (action) {
|
||||
is VaultUnlockedNavBarAction.Internal.UserStateUpdateReceive -> {
|
||||
handleUserStateUpdateReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
// #region BottomTabViewModel Action Handlers
|
||||
@@ -84,6 +95,27 @@ class VaultUnlockedNavBarViewModel @Inject constructor(
|
||||
private fun handleSettingsTabClicked() {
|
||||
sendEvent(VaultUnlockedNavBarEvent.NavigateToSettingsScreen)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the nav bar title according to whether the user is part of any organizations or not.
|
||||
*/
|
||||
private fun handleUserStateUpdateReceive(
|
||||
action: VaultUnlockedNavBarAction.Internal.UserStateUpdateReceive,
|
||||
) {
|
||||
val hasOrganizations = action
|
||||
.userState
|
||||
?.activeAccount
|
||||
?.organizations
|
||||
?.isNotEmpty()
|
||||
?: false
|
||||
val vaultRes = if (hasOrganizations) R.string.vaults else R.string.my_vault
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
vaultNavBarLabelRes = vaultRes,
|
||||
vaultNavBarContentDescriptionRes = vaultRes,
|
||||
)
|
||||
}
|
||||
}
|
||||
// #endregion BottomTabViewModel Action Handlers
|
||||
}
|
||||
|
||||
@@ -100,24 +132,36 @@ data class VaultUnlockedNavBarState(
|
||||
*/
|
||||
sealed class VaultUnlockedNavBarAction {
|
||||
/**
|
||||
* click Generator tab.
|
||||
* Click Generator tab.
|
||||
*/
|
||||
data object GeneratorTabClick : VaultUnlockedNavBarAction()
|
||||
|
||||
/**
|
||||
* click Send tab.
|
||||
* Click Send tab.
|
||||
*/
|
||||
data object SendTabClick : VaultUnlockedNavBarAction()
|
||||
|
||||
/**
|
||||
* click Vault tab.
|
||||
* Click Vault tab.
|
||||
*/
|
||||
data object VaultTabClick : VaultUnlockedNavBarAction()
|
||||
|
||||
/**
|
||||
* click Settings tab.
|
||||
* Click Settings tab.
|
||||
*/
|
||||
data object SettingsTabClick : VaultUnlockedNavBarAction()
|
||||
|
||||
/**
|
||||
* Models actions that the [VaultUnlockedNavBarViewModel] itself might send.
|
||||
*/
|
||||
sealed class Internal : VaultUnlockedNavBarAction() {
|
||||
/**
|
||||
* Indicates a change in user state has been received.
|
||||
*/
|
||||
data class UserStateUpdateReceive(
|
||||
val userState: UserState?,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -117,6 +117,11 @@ interface IntentManager {
|
||||
requestCode: Int,
|
||||
): PendingIntent
|
||||
|
||||
/**
|
||||
* Open the default email app on device.
|
||||
*/
|
||||
fun startDefaultEmailApplication()
|
||||
|
||||
/**
|
||||
* Represents file information.
|
||||
*/
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user