Compare commits

...

6 Commits

21 changed files with 921 additions and 66 deletions

View File

@@ -36,6 +36,8 @@ import com.x8bit.bitwarden.data.platform.manager.util.ObserveScreenDataEffect
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.accessibilityDisclosureDestination
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.navigateToAccessibilityDisclosure
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.cookieAcquisitionDestination
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCookieAcquisition
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.debugMenuDestination
@@ -110,6 +112,7 @@ class MainActivity : AppCompatActivity() {
)
}
@Suppress("LongMethod")
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
var shouldShowSplashScreen = true
@@ -166,6 +169,10 @@ class MainActivity : AppCompatActivity() {
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
accessibilityDisclosureDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
}
}
}
@@ -254,6 +261,10 @@ class MainActivity : AppCompatActivity() {
navController.navigateToLocalNetworkAccess()
}
MainEvent.NavigateToAccessibilityDisclosure -> {
navController.navigateToAccessibilityDisclosure()
}
is MainEvent.UpdateAppLocale -> {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(event.localeName),

View File

@@ -43,6 +43,7 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
@@ -151,6 +152,12 @@ class MainViewModel @Inject constructor(
.onEach(::trySendAction)
.launchIn(viewModelScope)
settingsRepository
.hasShownAccessibilityDisclaimerFlow
.map { MainAction.Internal.HasShownAccessibilityDisclaimerUpdate(it) }
.onEach(::trySendAction)
.launchIn(viewModelScope)
merge(
authRepository
.userStateFlow
@@ -235,6 +242,17 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
is MainAction.Internal.LocalNetworkAccessRequired -> handleLocalNetworkAccessRequired()
is MainAction.Internal.ResizeHasBeenRequested -> handleResizeHasBeenRequested()
is MainAction.Internal.HasShownAccessibilityDisclaimerUpdate -> {
handleHasShownAccessibilityDisclaimerUpdate(action)
}
}
}
private fun handleHasShownAccessibilityDisclaimerUpdate(
action: MainAction.Internal.HasShownAccessibilityDisclaimerUpdate,
) {
if (!action.hasBeenShown) {
sendEvent(MainEvent.NavigateToAccessibilityDisclosure)
}
}
@@ -692,6 +710,11 @@ sealed class MainAction {
* Indicates that resize has been requested on the Activity
*/
data object ResizeHasBeenRequested : Internal()
/**
* Indicates that the accessibility disclaimer has been displayed.
*/
data class HasShownAccessibilityDisclaimerUpdate(val hasBeenShown: Boolean) : Internal()
}
}
@@ -731,6 +754,11 @@ sealed class MainEvent {
*/
data object NavigateToLocalNetworkAccess : MainEvent()
/**
* Navigate to the accessibility disclosure screen.
*/
data object NavigateToAccessibilityDisclosure : MainEvent()
/**
* Indicates that the app language has been updated.
*/

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.billing.repository.util
import com.bitwarden.network.model.BitwardenDiscountJson
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CadenceTypeJson
import com.bitwarden.network.model.CartItemJson
import com.bitwarden.network.model.DiscountTypeJson
import com.bitwarden.network.model.SubscriptionStatusJson
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
@@ -11,28 +12,35 @@ import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo
import java.math.BigDecimal
import java.math.RoundingMode
private val PERCENT_DIVISOR: BigDecimal = BigDecimal("100")
private const val MONEY_SCALE: Int = 2
/**
* Maps a [BitwardenSubscriptionResponseJson] into a [SubscriptionInfo] domain
* model.
*
* `discountAmount` is resolved at mapping time: fixed-amount discounts pass
* through as-is; percent-off discounts apply to the password manager subtotal
* (`seatsCost + storageCost`). `nextChargeTotal` is computed client-side as
* `seatsCost + storageCost - discountAmount + estimatedTax` because the server
* Each line item's `cost` is a per-unit price, so its contribution is
* `cost * quantity`. Two discount channels are combined into `discountAmount`:
* the cart-level discount applies to the password manager subtotal
* (`seatsCost + storageCost`), and the Password Manager seats item-level
* discount applies to the seats line total. Item-level discounts on other line
* items are intentionally ignored, mirroring the web client. Fixed-amount
* discounts pass through as-is; percent-off discounts treat a value below 1 as
* an already-decimal fraction and round half-up. `nextChargeTotal` is computed
* client-side as `subtotal - discountAmount + estimatedTax` because the server
* does not expose a precomputed total.
*/
fun BitwardenSubscriptionResponseJson.toSubscriptionInfo(): SubscriptionInfo {
val seatsCost = cart.passwordManager.seats.cost
val storageCost = cart.passwordManager.additionalStorage?.cost
val discountAmount = cart.discount?.toMoneyAmount(
subtotal = seatsCost + (storageCost ?: BigDecimal.ZERO),
)
val seatsCost = cart.passwordManager.seats.lineTotal()
val storageCost = cart.passwordManager.additionalStorage?.lineTotal()
val subtotal = seatsCost + (storageCost ?: BigDecimal.ZERO)
val cartDiscount = cart.discount?.toDiscountAmount(baseAmount = subtotal)
val seatsDiscount = cart.passwordManager.seats.discount
?.toDiscountAmount(baseAmount = seatsCost)
val discountAmount = listOfNotNull(cartDiscount, seatsDiscount)
.takeIf { it.isNotEmpty() }
?.reduce(BigDecimal::add)
val estimatedTax = cart.estimatedTax
val nextChargeTotal = seatsCost +
(storageCost ?: BigDecimal.ZERO) -
val nextChargeTotal = subtotal -
(discountAmount ?: BigDecimal.ZERO) +
estimatedTax
@@ -76,16 +84,18 @@ private fun BitwardenSubscriptionResponseJson.toPremiumSubscriptionStatus():
SubscriptionStatusJson.PAUSED -> PremiumSubscriptionStatus.PAUSED
}
private fun CartItemJson.lineTotal(): BigDecimal = cost.multiply(quantity.toBigDecimal())
private fun CadenceTypeJson.toPlanCadence(): PlanCadence = when (this) {
CadenceTypeJson.ANNUALLY -> PlanCadence.ANNUALLY
CadenceTypeJson.MONTHLY -> PlanCadence.MONTHLY
}
private fun BitwardenDiscountJson.toMoneyAmount(subtotal: BigDecimal): BigDecimal =
private fun BitwardenDiscountJson.toDiscountAmount(baseAmount: BigDecimal): BigDecimal =
when (type) {
DiscountTypeJson.AMOUNT_OFF -> value
DiscountTypeJson.PERCENT_OFF ->
subtotal
.multiply(value)
.divide(PERCENT_DIVISOR, MONEY_SCALE, RoundingMode.HALF_EVEN)
DiscountTypeJson.PERCENT_OFF -> {
val percentage = if (value < BigDecimal.ONE) value else value.movePointLeft(2)
baseAmount.multiply(percentage).setScale(MONEY_SCALE, RoundingMode.HALF_UP)
}
}

View File

@@ -40,6 +40,17 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
*/
var initialAutofillDialogShown: Boolean?
/**
* Indicates if the accessibility disclaimer has been displayed to the user.
*/
var hasShownAccessibilityDisclaimer: Boolean?
/**
* Emits up-to-date values indicating if the accessibility disclaimer has been displayed to
* the user.
*/
val hasShownAccessibilityDisclaimerFlow: Flow<Boolean?>
/**
* The currently persisted app theme (or `null` if not set).
*/

View File

@@ -35,6 +35,7 @@ private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "accountBiometricInteg
private const val CRASH_LOGGING_ENABLED_KEY = "crashLoggingEnabled"
private const val CLEAR_CLIPBOARD_INTERVAL_KEY = "clearClipboard"
private const val INITIAL_AUTOFILL_DIALOG_SHOWN = "addSitePromptShown"
private const val HAS_SHOWN_ACCESSIBILITY_DISCLAIMER_KEY = "hasShownAccessibilityDisclaimer"
private const val HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY = "hasUserLoggedInOrCreatedAccount"
private const val SHOW_AUTOFILL_SETTING_BADGE = "showAutofillSettingBadge"
private const val SHOW_BROWSER_AUTOFILL_SETTING_BADGE = "showBrowserAutofillSettingBadge"
@@ -128,6 +129,8 @@ class SettingsDiskSourceImpl(
private val mutableIsDynamicColorsEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableHasShownAccessibilityDisclaimerFlow = bufferedMutableSharedFlow<Boolean?>()
init {
migrateScreenCaptureSetting()
}
@@ -167,6 +170,17 @@ class SettingsDiskSourceImpl(
)
}
override var hasShownAccessibilityDisclaimer: Boolean?
set(value) {
putBoolean(HAS_SHOWN_ACCESSIBILITY_DISCLAIMER_KEY, value)
mutableHasShownAccessibilityDisclaimerFlow.tryEmit(value)
}
get() = getBoolean(HAS_SHOWN_ACCESSIBILITY_DISCLAIMER_KEY)
override val hasShownAccessibilityDisclaimerFlow: Flow<Boolean?>
get() = mutableHasShownAccessibilityDisclaimerFlow
.onSubscription { emit(hasShownAccessibilityDisclaimer) }
override var systemBiometricIntegritySource: String?
get() = getString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY)
set(value) {
@@ -270,6 +284,7 @@ class SettingsDiskSourceImpl(
// - Upgraded to Premium action card consumed
// - Upgraded to Premium action card pending
// - Premium upgrade pending
// - Has shown accessibility disclaimer dialog
}
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =

View File

@@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
@@ -95,15 +96,15 @@ class PolicyManagerImpl(
},
featureFlagManager.getFeatureFlagFlow(key = FlagKey.PoliciesInAcceptedState),
) { policies, organizations, isEnabled ->
this
.filterPolicies(
type = type,
policies = policies,
organizations = organizations,
isPoliciesInAcceptedStateEnabled = isEnabled,
)
.orEmpty()
filterPolicies(
type = type,
policies = policies,
organizations = organizations,
isPoliciesInAcceptedStateEnabled = isEnabled,
)
}
// We do not have any policies yet if it is null, so do not emit at all.
.filterNotNull()
private fun filterPolicies(
type: PolicyType,

View File

@@ -187,6 +187,16 @@ interface SettingsRepository : FlightRecorderManager {
*/
val isScreenCaptureAllowedStateFlow: StateFlow<Boolean>
/**
* Whether the accessibility disclaimer has been displayed to the user.
*/
val hasShownAccessibilityDisclaimerFlow: StateFlow<Boolean>
/**
* Stores that the accessibility disclaimer has been displayed to the user.
*/
fun accessibilityDisclaimerHasBeenShown()
/**
* Disables autofill if it is currently enabled.
*/

View File

@@ -372,6 +372,16 @@ class SettingsRepositoryImpl(
initialValue = isScreenCaptureAllowed,
)
override val hasShownAccessibilityDisclaimerFlow: StateFlow<Boolean>
get() = settingsDiskSource
.hasShownAccessibilityDisclaimerFlow
.map { it ?: false }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Lazily,
initialValue = settingsDiskSource.hasShownAccessibilityDisclaimer ?: false,
)
init {
policyManager
.getActivePoliciesFlow(type = PolicyType.MAXIMUM_VAULT_TIMEOUT)
@@ -379,6 +389,10 @@ class SettingsRepositoryImpl(
.launchIn(unconfinedScope)
}
override fun accessibilityDisclaimerHasBeenShown() {
settingsDiskSource.hasShownAccessibilityDisclaimer = true
}
override fun disableAutofill() {
autofillManager.disableAutofillServices()

View File

@@ -0,0 +1,40 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* The type-safe route for the accessibility disclosure screen.
*/
@OmitFromCoverage
@Serializable
data object AccessibilityDisclosureRoute
/**
* Add the accessibility disclosure screen to the nav graph.
*/
fun NavGraphBuilder.accessibilityDisclosureDestination(
onDismiss: () -> Unit,
onSplashScreenRemoved: () -> Unit,
) {
composableWithSlideTransitions<AccessibilityDisclosureRoute> {
AccessibilityDisclosureScreen(onDismiss = onDismiss)
// If we are displaying the accessibility disclosure screen, then we can just hide
// the splash screen.
onSplashScreenRemoved()
}
}
/**
* Navigate to the accessibility disclosure screen.
*/
fun NavController.navigateToAccessibilityDisclosure() {
this.navigate(route = AccessibilityDisclosureRoute) {
launchSingleTop = true
}
}

View File

@@ -0,0 +1,158 @@
package com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
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.only
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalExitManager
import com.bitwarden.ui.platform.manager.exit.ExitManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Top-level composable for the Accessibility Disclosure screen.
*/
@Composable
fun AccessibilityDisclosureScreen(
onDismiss: () -> Unit,
viewModel: AccessibilityDisclosureViewModel = hiltViewModel(),
exitManager: ExitManager = LocalExitManager.current,
) {
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is AccessibilityDisclosureEvent.Dismiss -> onDismiss()
is AccessibilityDisclosureEvent.CloseApp -> exitManager.exitApplication()
}
}
BackHandler { viewModel.trySendAction(AccessibilityDisclosureAction.CloseAppClick) }
BitwardenScaffold(
contentWindowInsets = ScaffoldDefaults
.contentWindowInsets
.union(WindowInsets.displayCutout)
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
) {
AccessibilityDisclosureContent(
onAcceptClick = {
viewModel.trySendAction(AccessibilityDisclosureAction.AcceptClicked)
},
onCloseAppClick = {
viewModel.trySendAction(AccessibilityDisclosureAction.CloseAppClick)
},
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun AccessibilityDisclosureContent(
onAcceptClick: () -> Unit,
onCloseAppClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.verticalScroll(state = rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(height = 32.dp))
Image(
painter = rememberVectorPainter(id = BitwardenDrawable.ill_autofill),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = Modifier
.standardHorizontalMargin()
.size(size = 100.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
Text(
text = stringResource(id = BitwardenString.accessibility_service_disclosure),
style = BitwardenTheme.typography.headlineSmall,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
Text(
text = stringResource(id = BitwardenString.accessibility_disclosure_start_up_text),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.accept),
onClick = onAcceptClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenOutlinedButton(
label = stringResource(id = BitwardenString.close_app),
onClick = onCloseAppClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Preview(showBackground = true)
@Composable
private fun AccessibilityDisclosureContent_preview() {
BitwardenTheme {
AccessibilityDisclosureContent(
onAcceptClick = {},
onCloseAppClick = {},
modifier = Modifier.fillMaxSize(),
)
}
}

View File

@@ -0,0 +1,74 @@
package com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure
import android.os.Parcelable
import com.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
/**
* ViewModel for the Accessibility Disclosure screen.
*/
@HiltViewModel
class AccessibilityDisclosureViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
) : BaseViewModel<
AccessibilityDisclosureState,
AccessibilityDisclosureEvent,
AccessibilityDisclosureAction,
>(
initialState = AccessibilityDisclosureState,
) {
override fun handleAction(action: AccessibilityDisclosureAction) {
when (action) {
AccessibilityDisclosureAction.AcceptClicked -> handleAcceptClicked()
AccessibilityDisclosureAction.CloseAppClick -> handleCloseAppClick()
}
}
private fun handleAcceptClicked() {
settingsRepository.accessibilityDisclaimerHasBeenShown()
sendEvent(AccessibilityDisclosureEvent.Dismiss)
}
private fun handleCloseAppClick() {
sendEvent(AccessibilityDisclosureEvent.CloseApp)
}
}
/**
* State for the Accessibility Disclosure screen.
*/
@Parcelize
data object AccessibilityDisclosureState : Parcelable
/**
* Events for the Accessibility Disclosure screen.
*/
sealed class AccessibilityDisclosureEvent {
/**
* Navigate back, dismissing the screen.
*/
data object Dismiss : AccessibilityDisclosureEvent()
/**
* Closes the app.
*/
data object CloseApp : AccessibilityDisclosureEvent()
}
/**
* Actions for the Accessibility Disclosure screen.
*/
sealed class AccessibilityDisclosureAction {
/**
* User clicked the accept button.
*/
data object AcceptClicked : AccessibilityDisclosureAction()
/**
* User clicked the close app button.
*/
data object CloseAppClick : AccessibilityDisclosureAction()
}

View File

@@ -114,6 +114,7 @@ class MainViewModelTest : BaseViewModelTest() {
private val mutableAppLanguageFlow = MutableStateFlow(AppLanguage.DEFAULT)
private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true)
private val mutableIsDynamicColorsEnabledFlow = MutableStateFlow(false)
private val mutableHasShownAccessibilityDisclaimerFlow = MutableStateFlow(true)
private val settingsRepository = mockk<SettingsRepository> {
every { appTheme } returns AppTheme.DEFAULT
every { appThemeStateFlow } returns mutableAppThemeFlow
@@ -124,6 +125,10 @@ class MainViewModelTest : BaseViewModelTest() {
every { appLanguage = any() } just runs
every { isDynamicColorsEnabled } returns false
every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow
every {
hasShownAccessibilityDisclaimerFlow
} returns mutableHasShownAccessibilityDisclaimerFlow
every { accessibilityDisclaimerHasBeenShown() } just runs
}
private val authRepository = mockk<AuthRepository> {
every { activeUserId } returns DEFAULT_USER_STATE.activeUserId
@@ -1385,6 +1390,23 @@ class MainViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `on HasShownAccessibilityDisclaimerUpdate with false should show the accessibility disclosure`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
// We skip the first 2 events because they are the default appTheme and appLanguage
skipItems(2)
mutableHasShownAccessibilityDisclaimerFlow.value = false
assertEquals(MainEvent.NavigateToAccessibilityDisclosure, awaitItem())
mutableHasShownAccessibilityDisclaimerFlow.value = true
expectNoEvents()
}
}
private fun createViewModel(
initialSpecialCircumstance: SpecialCircumstance? = null,
): MainViewModel = MainViewModel(

View File

@@ -126,6 +126,24 @@ class BitwardenSubscriptionResponseJsonExtensionsTest {
assertEquals(BigDecimal("24.00"), info.storageCost)
}
@Test
fun `toSubscriptionInfo storageCost multiplies unit cost by quantity`() {
val info = buildResponse(
storageCost = BigDecimal("4"),
storageQuantity = 3,
).toSubscriptionInfo()
assertEquals(BigDecimal("12"), info.storageCost)
}
@Test
fun `toSubscriptionInfo seatsCost multiplies unit cost by quantity`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
seatsQuantity = 2,
).toSubscriptionInfo()
assertEquals(BigDecimal("39.60"), info.seatsCost)
}
@Test
fun `toSubscriptionInfo discountAmount is null when no discount`() {
val info = buildResponse(discount = null).toSubscriptionInfo()
@@ -157,6 +175,77 @@ class BitwardenSubscriptionResponseJsonExtensionsTest {
assertEquals(BigDecimal("4.50"), info.discountAmount)
}
@Test
fun `toSubscriptionInfo PERCENT_OFF discount treats value below one as a decimal fraction`() {
val info = buildResponse(
seatsCost = BigDecimal("20.00"),
discount = BitwardenDiscountJson(
type = DiscountTypeJson.PERCENT_OFF,
value = BigDecimal("0.5"),
),
).toSubscriptionInfo()
assertEquals(BigDecimal("10.00"), info.discountAmount)
}
@Test
fun `toSubscriptionInfo applies PM seats item-level discount`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
seatsDiscount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("5.00"),
),
).toSubscriptionInfo()
assertEquals(BigDecimal("5.00"), info.discountAmount)
assertEquals(BigDecimal("14.80"), info.nextChargeTotal)
}
@Test
fun `toSubscriptionInfo PERCENT_OFF seats item discount applies to seats line total`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
seatsQuantity = 2,
seatsDiscount = BitwardenDiscountJson(
type = DiscountTypeJson.PERCENT_OFF,
value = BigDecimal("10.00"),
),
).toSubscriptionInfo()
assertEquals(BigDecimal("3.96"), info.discountAmount)
}
@Test
fun `toSubscriptionInfo combines cart-level and PM seats item discounts`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
seatsDiscount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("3.00"),
),
discount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("2.00"),
),
estimatedTax = BigDecimal("1.00"),
).toSubscriptionInfo()
assertEquals(BigDecimal("5.00"), info.discountAmount)
assertEquals(BigDecimal("15.80"), info.nextChargeTotal)
}
@Test
fun `toSubscriptionInfo ignores additionalStorage item-level discount`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
storageCost = BigDecimal("4"),
storageQuantity = 3,
storageDiscount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("5.00"),
),
).toSubscriptionInfo()
assertNull(info.discountAmount)
assertEquals(BigDecimal("31.80"), info.nextChargeTotal)
}
@Test
fun `toSubscriptionInfo passes estimatedTax through`() {
val info = buildResponse(estimatedTax = BigDecimal("3.85")).toSubscriptionInfo()
@@ -178,6 +267,31 @@ class BitwardenSubscriptionResponseJsonExtensionsTest {
assertEquals(BigDecimal("45.55"), info.nextChargeTotal)
}
@Test
fun `toSubscriptionInfo nextChargeTotal multiplies storage line by quantity`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
storageCost = BigDecimal("4"),
storageQuantity = 3,
estimatedTax = BigDecimal("2.04"),
).toSubscriptionInfo()
assertEquals(BigDecimal("33.84"), info.nextChargeTotal)
}
@Test
fun `toSubscriptionInfo PERCENT_OFF discount applies to quantity-multiplied subtotal`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
storageCost = BigDecimal("4"),
storageQuantity = 3,
discount = BitwardenDiscountJson(
type = DiscountTypeJson.PERCENT_OFF,
value = BigDecimal("10.00"),
),
).toSubscriptionInfo()
assertEquals(BigDecimal("3.18"), info.discountAmount)
}
@Test
fun `toSubscriptionInfo nextChargeTotal with minimal cart equals seatsCost`() {
// User-provided JSON: 19.80 + 0 - 0 + 0 = 19.80
@@ -217,7 +331,11 @@ class BitwardenSubscriptionResponseJsonExtensionsTest {
status: SubscriptionStatusJson = SubscriptionStatusJson.ACTIVE,
cadence: CadenceTypeJson = CadenceTypeJson.ANNUALLY,
seatsCost: BigDecimal = BigDecimal("19.80"),
seatsQuantity: Long = 1,
seatsDiscount: BitwardenDiscountJson? = null,
storageCost: BigDecimal? = null,
storageQuantity: Long = 1,
storageDiscount: BitwardenDiscountJson? = null,
discount: BitwardenDiscountJson? = null,
estimatedTax: BigDecimal = BigDecimal.ZERO,
storage: StorageJson? = null,
@@ -232,16 +350,16 @@ class BitwardenSubscriptionResponseJsonExtensionsTest {
passwordManager = PasswordManagerCartItemsJson(
seats = CartItemJson(
translationKey = "premiumMembership",
quantity = 1,
quantity = seatsQuantity,
cost = seatsCost,
discount = null,
discount = seatsDiscount,
),
additionalStorage = storageCost?.let {
CartItemJson(
translationKey = "additionalStorage",
quantity = 1,
quantity = storageQuantity,
cost = it,
discount = null,
discount = storageDiscount,
)
},
),

View File

@@ -473,6 +473,42 @@ class SettingsDiskSourceTest {
}
}
@Test
fun `hasShownAccessibilityDisclaimer should pull from and update SharedPreferences`() {
val hasShownAccessibilityDisclaimerKey =
"bwPreferencesStorage:hasShownAccessibilityDisclaimer"
val expected = true
assertNull(settingsDiskSource.hasShownAccessibilityDisclaimer)
fakeSharedPreferences.edit {
putBoolean(hasShownAccessibilityDisclaimerKey, expected)
}
assertEquals(
expected,
settingsDiskSource.hasShownAccessibilityDisclaimer,
)
settingsDiskSource.hasShownAccessibilityDisclaimer = false
assertFalse(fakeSharedPreferences.getBoolean(hasShownAccessibilityDisclaimerKey, true))
}
@Suppress("MaxLineLength")
@Test
fun `hasShownAccessibilityDisclaimerFlow should react to changes in hasShownAccessibilityDisclaimer`() =
runTest {
settingsDiskSource.hasShownAccessibilityDisclaimerFlow.test {
// The initial values of the Flow and the property are in sync
assertNull(settingsDiskSource.hasShownAccessibilityDisclaimer)
assertNull(awaitItem())
settingsDiskSource.hasShownAccessibilityDisclaimer = true
assertEquals(true, awaitItem())
settingsDiskSource.hasShownAccessibilityDisclaimer = false
assertEquals(false, awaitItem())
}
}
@Test
fun `getVaultTimeoutInMinutes when values are present should pull from SharedPreferences`() {
val vaultTimeoutBaseKey = "bwPreferencesStorage:vaultTimeout"

View File

@@ -93,6 +93,7 @@ class FakeSettingsDiskSource(
private var hasSeenAddLoginCoachMark: Boolean? = null
private var hasSeenGeneratorCoachMark: Boolean? = null
private var storedIsDynamicColorsEnabled: Boolean? = null
private var storedHasShownAccessibilityDisclaimer: Boolean? = null
private var storedBrowserAutofillDialogReshowTime: Instant? = null
private val mutableShowAutoFillSettingBadgeFlowMap =
@@ -110,6 +111,8 @@ class FakeSettingsDiskSource(
private val mutableIsDynamicColorsEnabled =
bufferedMutableSharedFlow<Boolean?>()
private val mutableHasShownAccessibilityDisclaimerFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableVaultRegisteredForExportFlow =
bufferedMutableSharedFlow<Boolean?>()
@@ -162,6 +165,18 @@ class FakeSettingsDiskSource(
emit(isDynamicColorsEnabled)
}
override var hasShownAccessibilityDisclaimer: Boolean?
get() = storedHasShownAccessibilityDisclaimer
set(value) {
storedHasShownAccessibilityDisclaimer = value
mutableHasShownAccessibilityDisclaimerFlow.tryEmit(value)
}
override val hasShownAccessibilityDisclaimerFlow: Flow<Boolean?>
get() = mutableHasShownAccessibilityDisclaimerFlow.onSubscription {
emit(hasShownAccessibilityDisclaimer)
}
override var screenCaptureAllowed: Boolean?
get() = storedScreenCaptureAllowed
set(value) {

View File

@@ -1119,6 +1119,30 @@ class SettingsRepositoryTest {
}
}
@Test
fun `hasShownAccessibilityDisclaimerFlow should emit changes from SettingsDiskSource`() =
runTest {
fakeSettingsDiskSource.hasShownAccessibilityDisclaimer = null
settingsRepository.hasShownAccessibilityDisclaimerFlow.test {
assertFalse(awaitItem())
fakeSettingsDiskSource.hasShownAccessibilityDisclaimer = true
assertTrue(awaitItem())
fakeSettingsDiskSource.hasShownAccessibilityDisclaimer = false
assertFalse(awaitItem())
}
}
@Test
fun `accessibilityDisclaimerHasBeenShown should update SettingsDiskSource`() {
assertNull(fakeSettingsDiskSource.hasShownAccessibilityDisclaimer)
settingsRepository.accessibilityDisclaimerHasBeenShown()
assertTrue(fakeSettingsDiskSource.hasShownAccessibilityDisclaimer == true)
}
@Test
fun `clearClipboardFrequency should pull from and update SettingsDiskSource`() = runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE

View File

@@ -0,0 +1,85 @@
package com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.platform.manager.exit.ExitManager
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class AccessibilityDisclosureScreenTest : BitwardenComposeTest() {
private var onDismissCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<AccessibilityDisclosureEvent>()
private val mutableStateFlow = MutableStateFlow(AccessibilityDisclosureState)
private val viewModel = mockk<AccessibilityDisclosureViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
private val exitManager = mockk<ExitManager> {
every { exitApplication() } just runs
}
@Before
fun setUp() {
setContent(exitManager = exitManager) {
AccessibilityDisclosureScreen(
onDismiss = { onDismissCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `accept button click should send AcceptClicked action`() {
composeTestRule
.onNodeWithText(text = "Accept")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(AccessibilityDisclosureAction.AcceptClicked)
}
}
@Test
fun `close app button click should send CloseAppClick action`() {
composeTestRule
.onNodeWithText(text = "Close app")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(AccessibilityDisclosureAction.CloseAppClick)
}
}
@Test
fun `Dismiss event should call onDismiss`() {
mutableEventFlow.tryEmit(AccessibilityDisclosureEvent.Dismiss)
assertTrue(onDismissCalled)
}
@Test
fun `CloseApp event should exit the application`() {
mutableEventFlow.tryEmit(AccessibilityDisclosureEvent.CloseApp)
verify(exactly = 1) {
exitManager.exitApplication()
}
}
@Test
fun `system back should not dismiss the screen`() {
backDispatcher?.onBackPressed()
verify(exactly = 1) {
viewModel.trySendAction(AccessibilityDisclosureAction.CloseAppClick)
}
}
}

View File

@@ -0,0 +1,55 @@
package com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class AccessibilityDisclosureViewModelTest : BaseViewModelTest() {
private val settingsRepository: SettingsRepository = mockk {
every { accessibilityDisclaimerHasBeenShown() } just runs
}
@Test
fun `initial state should be correct`() {
val viewModel = createViewModel()
assertEquals(AccessibilityDisclosureState, viewModel.stateFlow.value)
}
@Test
fun `AcceptClicked should mark disclaimer as shown and emit Dismiss event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(AccessibilityDisclosureAction.AcceptClicked)
assertEquals(AccessibilityDisclosureEvent.Dismiss, awaitItem())
}
verify(exactly = 1) {
settingsRepository.accessibilityDisclaimerHasBeenShown()
}
}
@Test
fun `CloseAppClick should emit CloseApp event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(AccessibilityDisclosureAction.CloseAppClick)
assertEquals(AccessibilityDisclosureEvent.CloseApp, awaitItem())
}
verify(exactly = 0) {
settingsRepository.accessibilityDisclaimerHasBeenShown()
}
}
private fun createViewModel(): AccessibilityDisclosureViewModel =
AccessibilityDisclosureViewModel(
settingsRepository = settingsRepository,
)
}

View File

@@ -1,16 +1,35 @@
package com.bitwarden.ui.platform.components.dialog
import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
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.testTagsAsResourceId
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.dialog.util.maxDialogHeight
import com.bitwarden.ui.platform.components.dialog.util.maxDialogWidth
import com.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenString
@@ -36,54 +55,85 @@ fun BitwardenBasicDialog(
throwable: Throwable? = null,
intentManager: IntentManager = LocalIntentManager.current,
) {
AlertDialog(
Dialog(
onDismissRequest = onDismissRequest,
confirmButton = {
BitwardenTextButton(
label = confirmButtonLabel,
onClick = onDismissRequest,
modifier = Modifier.testTag(tag = "AcceptAlertButton"),
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val configuration = LocalConfiguration.current
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.semantics {
testTagsAsResourceId = true
testTag = "AlertPopup"
}
.requiredHeightIn(max = configuration.maxDialogHeight)
.requiredWidthIn(max = configuration.maxDialogWidth)
.background(
color = BitwardenTheme.colorScheme.background.primary,
shape = BitwardenTheme.shapes.dialog,
),
horizontalAlignment = Alignment.End,
) {
Spacer(modifier = Modifier.height(height = 24.dp))
title?.let {
Text(
text = it,
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.headlineSmall,
modifier = Modifier
.testTag(tag = "AlertTitleText")
.padding(horizontal = 24.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
}
if (scrollState.canScrollBackward) {
BitwardenHorizontalDivider()
}
Text(
text = message,
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier
.testTag(tag = "AlertContentText")
.weight(weight = 1f, fill = false)
.verticalScroll(state = scrollState)
.padding(horizontal = 24.dp)
.fillMaxWidth(),
)
},
dismissButton = throwable
?.let { error ->
{
if (scrollState.canScrollForward) {
BitwardenHorizontalDivider()
}
Spacer(modifier = Modifier.height(height = 24.dp))
FlowRow(
horizontalArrangement = Arrangement.End,
modifier = Modifier.padding(horizontal = 16.dp),
) {
throwable?.let { error ->
BitwardenTextButton(
label = stringResource(id = BitwardenString.share_error_details),
onClick = {
intentManager.shareErrorReport(throwable = error)
onDismissRequest()
},
modifier = Modifier.testTag(tag = "ShareErrorDetailsAlertButton"),
modifier = Modifier
.testTag(tag = "ShareErrorDetailsAlertButton")
.padding(horizontal = 4.dp),
)
}
},
title = title?.let {
{
Text(
text = it,
style = BitwardenTheme.typography.headlineSmall,
modifier = Modifier.testTag(tag = "AlertTitleText"),
BitwardenTextButton(
label = confirmButtonLabel,
onClick = onDismissRequest,
modifier = Modifier
.testTag(tag = "AcceptAlertButton")
.padding(horizontal = 4.dp),
)
}
},
text = {
Text(
text = message,
style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier.testTag(tag = "AlertContentText"),
)
},
shape = BitwardenTheme.shapes.dialog,
containerColor = BitwardenTheme.colorScheme.background.primary,
iconContentColor = BitwardenTheme.colorScheme.icon.secondary,
titleContentColor = BitwardenTheme.colorScheme.text.primary,
textContentColor = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier.semantics {
testTagsAsResourceId = true
testTag = "AlertPopup"
},
)
Spacer(modifier = Modifier.height(height = 24.dp))
}
}
}
@Preview

View File

@@ -0,0 +1,76 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="201dp"
android:viewportWidth="200"
android:viewportHeight="201">
<path
android:name="secondary"
android:fillColor="#AAC3EF"
android:pathData="M0,42.67C0,33.46 7.46,26 16.67,26H183.33C192.54,26 200,33.46 200,42.67V159.33C200,168.54 192.54,176 183.33,176H16.67C7.46,176 0,168.54 0,159.33V42.67Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M183.33,30.17H16.67C9.76,30.17 4.17,35.76 4.17,42.67V159.33C4.17,166.24 9.76,171.83 16.67,171.83H183.33C190.24,171.83 195.83,166.24 195.83,159.33V42.67C195.83,35.76 190.24,30.17 183.33,30.17ZM16.67,26C7.46,26 0,33.46 0,42.67V159.33C0,168.54 7.46,176 16.67,176H183.33C192.54,176 200,168.54 200,159.33V42.67C200,33.46 192.54,26 183.33,26H16.67Z" />
<path
android:name="primary"
android:fillColor="#DBE5F6"
android:pathData="M18.75,57.25C18.75,54.95 20.62,53.08 22.92,53.08H177.08C179.38,53.08 181.25,54.95 181.25,57.25V82.25C181.25,84.55 179.38,86.42 177.08,86.42H22.92C20.62,86.42 18.75,84.55 18.75,82.25V57.25Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M177.08,57.25H22.92L22.92,82.25H177.08V57.25ZM22.92,53.08C20.62,53.08 18.75,54.95 18.75,57.25V82.25C18.75,84.55 20.62,86.42 22.92,86.42H177.08C179.38,86.42 181.25,84.55 181.25,82.25V57.25C181.25,54.95 179.38,53.08 177.08,53.08H22.92Z" />
<path
android:name="primary"
android:fillColor="#DBE5F6"
android:pathData="M18.75,119.75C18.75,117.45 20.62,115.58 22.92,115.58H177.08C179.38,115.58 181.25,117.45 181.25,119.75V144.75C181.25,147.05 179.38,148.92 177.08,148.92H22.92C20.62,148.92 18.75,147.05 18.75,144.75V119.75Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M177.08,119.75H22.92L22.92,144.75H177.08V119.75ZM22.92,115.58C20.62,115.58 18.75,117.45 18.75,119.75V144.75C18.75,147.05 20.62,148.92 22.92,148.92H177.08C179.38,148.92 181.25,147.05 181.25,144.75V119.75C181.25,117.45 179.38,115.58 177.08,115.58H22.92Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M35.43,125.11C36.58,125.11 37.51,126.04 37.51,127.19V130.11L40.24,129.21C41.34,128.86 42.51,129.45 42.87,130.55C43.23,131.64 42.63,132.82 41.54,133.17L38.77,134.08L40.5,136.51C41.17,137.45 40.96,138.75 40.02,139.42C39.08,140.09 37.78,139.87 37.11,138.93L35.43,136.57L33.74,138.93C33.07,139.87 31.77,140.09 30.83,139.42C29.9,138.75 29.68,137.45 30.35,136.51L32.08,134.08L29.31,133.17C28.22,132.82 27.62,131.64 27.98,130.55C28.34,129.45 29.51,128.86 30.61,129.21L33.34,130.11V127.19C33.34,126.04 34.28,125.11 35.43,125.11ZM54.18,125.11C55.33,125.11 56.26,126.04 56.26,127.19V130.11L58.99,129.21C60.09,128.86 61.26,129.45 61.62,130.55C61.98,131.64 61.38,132.82 60.29,133.17L57.52,134.08L59.25,136.51C59.92,137.45 59.71,138.75 58.77,139.42C57.83,140.09 56.53,139.87 55.86,138.93L54.18,136.57L52.49,138.93C51.82,139.87 50.52,140.09 49.58,139.42C48.65,138.75 48.43,137.45 49.1,136.51L50.83,134.08L48.06,133.17C46.97,132.82 46.37,131.64 46.73,130.55C47.09,129.45 48.26,128.86 49.36,129.21L52.09,130.11V127.19C52.09,126.04 53.03,125.11 54.18,125.11ZM72.93,125.11C74.08,125.11 75.01,126.04 75.01,127.19V130.11L77.74,129.21C78.84,128.86 80.01,129.45 80.37,130.55C80.73,131.64 80.13,132.82 79.04,133.17L76.27,134.08L78,136.51C78.67,137.45 78.46,138.75 77.52,139.42C76.58,140.09 75.28,139.87 74.61,138.93L72.93,136.57L71.24,138.93C70.57,139.87 69.27,140.09 68.33,139.42C67.4,138.75 67.18,137.45 67.85,136.51L69.58,134.08L66.81,133.17C65.72,132.82 65.12,131.64 65.48,130.55C65.84,129.45 67.01,128.86 68.11,129.21L70.84,130.11V127.19C70.84,126.04 71.78,125.11 72.93,125.11ZM91.68,125.11C92.83,125.11 93.76,126.04 93.76,127.19V130.11L96.49,129.21C97.59,128.86 98.76,129.45 99.12,130.55C99.48,131.64 98.88,132.82 97.79,133.17L95.02,134.08L96.75,136.51C97.42,137.45 97.21,138.75 96.27,139.42C95.33,140.09 94.03,139.87 93.36,138.93L91.68,136.57L89.99,138.93C89.32,139.87 88.02,140.09 87.08,139.42C86.15,138.75 85.93,137.45 86.6,136.51L88.33,134.08L85.56,133.17C84.47,132.82 83.87,131.64 84.23,130.55C84.59,129.45 85.76,128.86 86.86,129.21L89.59,130.11V127.19C89.59,126.04 90.53,125.11 91.68,125.11ZM110.43,125.11C111.58,125.11 112.51,126.04 112.51,127.19V130.11L115.24,129.21C116.34,128.86 117.51,129.45 117.87,130.55C118.23,131.64 117.63,132.82 116.54,133.17L113.77,134.08L115.5,136.51C116.17,137.45 115.96,138.75 115.02,139.42C114.08,140.09 112.78,139.87 112.11,138.93L110.43,136.57L108.74,138.93C108.07,139.87 106.77,140.09 105.83,139.42C104.9,138.75 104.68,137.45 105.35,136.51L107.08,134.08L104.31,133.17C103.22,132.82 102.62,131.64 102.98,130.55C103.34,129.45 104.51,128.86 105.61,129.21L108.34,130.11V127.19C108.34,126.04 109.28,125.11 110.43,125.11Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M29.17,69.75C29.17,68.6 30.1,67.67 31.25,67.67L114.58,67.67C115.73,67.67 116.67,68.6 116.67,69.75C116.67,70.9 115.73,71.83 114.58,71.83L31.25,71.83C30.1,71.83 29.17,70.9 29.17,69.75Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M170.22,62.03C171.04,62.84 171.04,64.16 170.22,64.97L157.72,77.47C156.91,78.29 155.59,78.29 154.78,77.47L148.53,71.22C147.71,70.41 147.71,69.09 148.53,68.28C149.34,67.46 150.66,67.46 151.47,68.28L156.25,73.05L167.28,62.03C168.09,61.21 169.41,61.21 170.22,62.03Z" />
<path
android:name="accent"
android:fillColor="#FFBF00"
android:pathData="M191.67,132.25C191.67,146.06 180.47,157.25 166.67,157.25C152.86,157.25 141.67,146.06 141.67,132.25C141.67,118.44 152.86,107.25 166.67,107.25C180.47,107.25 191.67,118.44 191.67,132.25Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M166.67,153.08C178.17,153.08 187.5,143.76 187.5,132.25C187.5,120.74 178.17,111.42 166.67,111.42C155.16,111.42 145.83,120.74 145.83,132.25C145.83,143.76 155.16,153.08 166.67,153.08ZM166.67,157.25C180.47,157.25 191.67,146.06 191.67,132.25C191.67,118.44 180.47,107.25 166.67,107.25C152.86,107.25 141.67,118.44 141.67,132.25C141.67,146.06 152.86,157.25 166.67,157.25Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M178.56,124.53C179.37,125.34 179.37,126.66 178.56,127.47L163.97,142.06C163.16,142.87 161.84,142.87 161.03,142.06L154.78,135.81C153.96,134.99 153.96,133.67 154.78,132.86C155.59,132.05 156.91,132.05 157.72,132.86L162.5,137.64L175.61,124.53C176.42,123.71 177.74,123.71 178.56,124.53Z" />
<path
android:name="accent"
android:fillColor="#FFBF00"
android:pathData="M191.67,69.75C191.67,83.56 180.47,94.75 166.67,94.75C152.86,94.75 141.67,83.56 141.67,69.75C141.67,55.94 152.86,44.75 166.67,44.75C180.47,44.75 191.67,55.94 191.67,69.75Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M166.67,90.58C178.17,90.58 187.5,81.26 187.5,69.75C187.5,58.24 178.17,48.92 166.67,48.92C155.16,48.92 145.83,58.24 145.83,69.75C145.83,81.26 155.16,90.58 166.67,90.58ZM166.67,94.75C180.47,94.75 191.67,83.56 191.67,69.75C191.67,55.94 180.47,44.75 166.67,44.75C152.86,44.75 141.67,55.94 141.67,69.75C141.67,83.56 152.86,94.75 166.67,94.75Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M178.56,62.03C179.37,62.84 179.37,64.16 178.56,64.97L163.97,79.56C163.16,80.37 161.84,80.37 161.03,79.56L154.78,73.31C153.96,72.49 153.96,71.17 154.78,70.36C155.59,69.55 156.91,69.55 157.72,70.36L162.5,75.14L175.61,62.03C176.42,61.21 177.74,61.21 178.56,62.03Z" />
</vector>

View File

@@ -78,6 +78,7 @@
<string name="bitwarden_autofill_service">Bitwarden Autofill Service</string>
<string name="change_master_password">Change master password</string>
<string name="close">Close</string>
<string name="close_app">Close app</string>
<string name="continue_text">Continue</string>
<string name="create_account">Create account</string>
<string name="create_an_account">Create an account</string>
@@ -612,6 +613,7 @@ select Add TOTP to store the key safely</string>
<string name="forwarded_email_description">Generate an email alias with an external forwarding service.</string>
<string name="accessibility_service_disclosure">Accessibility Service Disclosure</string>
<string name="accessibility_disclosure_text">Bitwarden uses the Accessibility Service to search for login fields in apps and websites, then establish the appropriate field IDs for entering a username &amp; password when a match for the app or site is found. We do not store any of the information presented to us by the service, nor do we make any attempt to control any on-screen elements beyond text entry of credentials.</string>
<string name="accessibility_disclosure_start_up_text">Bitwarden offers an optional autofill method that uses Androids Accessibility Service to detect login fields in apps and websites. If you choose to enable it, Bitwarden will identify the appropriate fields and enter your credentials when a match is found. We do not store any information observed by the service, and we do not control any on-screen elements beyond credential entry.</string>
<string name="accept">Accept</string>
<string name="decline">Decline</string>
<string name="login_request_has_already_expired">Login request has already expired.</string>