Compare commits

..

42 Commits

Author SHA1 Message Date
André Bispo
1d05f5f758 [PM-6702] Add email verification feature flag to landing create account click 2024-08-16 14:46:31 +01:00
David Perez
bd55b9ce72 Add helper function for static retrofit instances (#3749) 2024-08-15 15:26:12 -05:00
David Perez
4726cb743a PM-10936: Add account apis for key connectors (#3748) 2024-08-15 13:53:48 -05:00
André Bispo
244d259804 [PM-6702] 5# Check your email screen (#3621) 2024-08-15 18:25:45 +01:00
André Bispo
eab94dde79 [PM-6702] 4# Start registration screen (#3620) 2024-08-15 17:15:45 +01:00
David Perez
2bb921b592 All booleans stored are nullable for consistency (#3747) 2024-08-15 11:02:01 -05:00
David Perez
18b58e75f8 PM-10909: Add persistance layer for usersKeyConnector (#3740) 2024-08-15 10:34:30 -05:00
André Bispo
e2cd3867dd [PM-6702] 3# Open app from App Link to CompleteRegistration (#3619) 2024-08-15 14:28:35 +01:00
David Perez
524b9e9a08 Add logging for SDK functionality in debug only (#3738) 2024-08-14 16:10:19 -05:00
David Perez
4b35484abb Update to AGP 8.5.2 (#3736) 2024-08-14 15:33:03 -05:00
David Perez
d305dc3081 Remove unused dangerfile (#3735) 2024-08-14 15:32:36 -05:00
David Perez
dde90a251a Update WorkManager to 2.9.1 (#3737) 2024-08-14 15:32:13 -05:00
David Perez
516cd72f66 Fix a failing test (#3734) 2024-08-14 14:46:04 -05:00
David Perez
63884e8518 PM-10894: Add flag for disabling remote feature flag configuration (#3729) 2024-08-14 14:06:09 -05:00
David Perez
8a4d436f1f Remove API specific autofill configuration file (#3730) 2024-08-14 13:54:03 -05:00
Dave Severns
ab279e2264 PM-10851 make the default top app bar reactive (#3726) 2024-08-14 13:42:08 -04:00
Shannon Draeker
2876d75a21 PM-10874: Fix biometrics auto-prompt (#3728) 2024-08-14 11:48:58 -04:00
Patrick Honkonen
aaa0ce4ecd [PM-10664] Display server error message during 2FA login (#3719) 2024-08-14 11:30:05 -04:00
David Perez
499bc20850 PM-10878: Access parcelable data in a safe manor across SDK versions (#3727) 2024-08-14 10:28:01 -05:00
David Perez
2bed4986a1 PM-10855: Update the minimum SDK to API 29 (Android 10) (#3723) 2024-08-14 09:23:13 -05:00
Dave Severns
151b081161 PM-10619 screen to generate master password (#3721) 2024-08-13 16:58:51 -04:00
Shannon Draeker
e3371b7620 PM-8522: Fix vault tab nav bar title when logging in (#3710) 2024-08-13 12:55:51 -04:00
David Perez
551f948644 PM-10835: Make config request after environment update (#3720) 2024-08-13 11:34:33 -05:00
André Bispo
4bd81782c8 [PM-6702] 2# Region load in complete registration step (#3618) 2024-08-13 15:22:34 +01:00
Shannon Draeker
4dbcec85bb PM-10118: Remember generator types (#3708) 2024-08-13 09:27:54 -04:00
Patrick Honkonen
5a0b1caecd [PM-10696] Dismiss vault unlock keyboard (#3718) 2024-08-12 16:11:30 -04:00
Dave Severns
2b13151bd1 PM-10620 prevent account lockout tips screen (#3711) 2024-08-12 08:38:23 -04:00
David Perez
5e643e11fd PM-10243: Update carousel text (#3714) 2024-08-09 16:15:23 -05:00
Patrick Honkonen
2789b1cc37 [PM-10697] Auto-focus on PIN Dialog field (#3713) 2024-08-09 16:26:54 -04:00
David Perez
b7a47eb91e Add helper method for standardizing margins (#3712) 2024-08-09 14:59:21 -05:00
Dave Severns
06f6f19255 PM-10071 ensure that lowercase letters take priority over the upperca… (#3707) 2024-08-09 14:55:24 -04:00
André Bispo
e717183239 [PM-6702] 1# Add service calls for email verification (#3617) 2024-08-09 19:38:52 +01:00
David Perez
edb87202d2 PM-10628: Add pin unlock to SetupUnlockViewModel (#3709) 2024-08-09 12:09:52 -05:00
David Perez
9b808058f5 Allow the ShowShareSheet event to be launched after the screen is paused (#3706) 2024-08-09 09:58:47 -05:00
github-actions[bot]
89589aa907 Autosync Crowdin Translations (#3703)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2024-08-09 14:33:21 +00:00
David Perez
805fea630c Add logic for biometric unlock to SetupUnlockScreen (#3702) 2024-08-09 09:09:41 -05:00
David Perez
145f8adf0c PM-10621: Add the SetupUnlockScreen (#3699) 2024-08-08 16:18:29 -05:00
Dave Severns
6bb5ef7417 [PM-10618] MP guidance screen with info and clickable card to navigate … (#3697) 2024-08-08 16:53:56 -04:00
Carlos Gonçalves
722726882b [PM-9833] Allow passkey deletion edit view (#3654) 2024-08-08 21:17:09 +01:00
David Perez
9ed30d7913 Fix a minor parcelable warning (#3701) 2024-08-08 14:47:43 -05:00
David Perez
6c5c0c7c03 PM-10729: Add a helper method for determining if the app is in portrait orientation (#3698) 2024-08-08 12:24:12 -05:00
Dave Severns
a57a7e099c [PM-10065] Use appropriate back behavior depending on how you are take to auth approval screen (#3695) 2024-08-08 11:37:20 -04:00
226 changed files with 9668 additions and 1280 deletions

View File

@@ -1 +0,0 @@
shroud.reportKover 'App', 'app/build/reports/kover/reportStandardDebug.xml', 80, 80, false

View File

@@ -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

View File

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

View File

@@ -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
}

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.util.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(

View File

@@ -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

View File

@@ -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)

View File

@@ -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.
*/

View File

@@ -5,8 +5,11 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJso
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import 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?>
}

View File

@@ -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(),
)

View File

@@ -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,
)
}

View File

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

View File

@@ -0,0 +1,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,
)

View File

@@ -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()
}

View File

@@ -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.
*/

View File

@@ -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)

View File

@@ -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>
}

View File

@@ -7,8 +7,10 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJso
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
@@ -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 }
}
}

View File

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

View File

@@ -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

View File

@@ -19,6 +19,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
@@ -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
}

View File

@@ -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,

View File

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

View File

@@ -0,0 +1,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,
)
}

View File

@@ -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?,

View File

@@ -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",
)

View File

@@ -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(),
)
}

View File

@@ -128,7 +128,6 @@ class AutofillProcessorImpl(
autofillRequest = autofillRequest,
)
val saveInfo = saveInfoBuilder.build(
autofillAppInfo = autofillAppInfo,
autofillPartition = autofillRequest.partition,
fillRequest = fillRequest,
packageName = autofillRequest.packageName,

View File

@@ -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,

View File

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

View File

@@ -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),
)
}
}

View File

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

View File

@@ -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
}

View File

@@ -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 {

View File

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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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,

View File

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

View File

@@ -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
}
}

View File

@@ -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.

View File

@@ -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
}

View File

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

View File

@@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrls
import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrlsOrDefault
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
@@ -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,
)
}

View File

@@ -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?>
}

View File

@@ -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
}

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

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

View File

@@ -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(),
)

View File

@@ -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(),
)
}

View File

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

View File

@@ -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,
)
}
}

View File

@@ -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)
},
)
}

View File

@@ -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)
},
)
}

View File

@@ -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)
},
)
}

View File

@@ -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)
},
)
}

View File

@@ -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,
)
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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),
)
},
)
}
}

View File

@@ -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() },
)
}
}

View File

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

View File

@@ -0,0 +1,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())
}
}
}

View File

@@ -0,0 +1,90 @@
package com.x8bit.bitwarden.ui.auth.feature.checkemail
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Models logic for the check email screen.
*/
@HiltViewModel
class CheckEmailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<CheckEmailState, CheckEmailEvent, CheckEmailAction>(
initialState = savedStateHandle[KEY_STATE]
?: CheckEmailState(
email = CheckEmailArgs(savedStateHandle).emailAddress,
),
) {
init {
// As state updates, write to saved state handle:
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: CheckEmailAction) {
when (action) {
CheckEmailAction.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()
}

View File

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

View File

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

View File

@@ -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.
*/

View File

@@ -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,
)
}
}

View File

@@ -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),
)
}
}

View File

@@ -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()
}

View File

@@ -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,
)
}
}

View File

@@ -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 = {},
)
}
}

View File

@@ -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()
}

View File

@@ -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,
)
}
}

View File

@@ -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 = {})
}
}

View File

@@ -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()
}

View File

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

View File

@@ -0,0 +1,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()
}
},
)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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 = {},
)
}
}
}

View File

@@ -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(),
),
)
}

View File

@@ -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(

View File

@@ -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()
}
/**

View File

@@ -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),
)

View File

@@ -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
}
}
}

View File

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

View File

@@ -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,
)
}
}
}

View File

@@ -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].
*

View File

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

View File

@@ -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

View File

@@ -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
}
}
}
}

View File

@@ -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.
*/

View File

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

View File

@@ -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

View File

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

View File

@@ -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()
}
}
/**

View File

@@ -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