mirror of
https://github.com/bitwarden/android.git
synced 2026-06-11 09:06:13 -05:00
Compare commits
6 Commits
sdlc/sdk-u
...
release/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbc73fdd5c | ||
|
|
43f70f5b3b | ||
|
|
15689fcace | ||
|
|
3e928489c7 | ||
|
|
d3590935b0 | ||
|
|
ee4b9823d1 |
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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? =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
76
ui/src/main/res/drawable/ill_autofill.xml
Normal file
76
ui/src/main/res/drawable/ill_autofill.xml
Normal 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>
|
||||
@@ -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 & 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 Android’s 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>
|
||||
|
||||
Reference in New Issue
Block a user