Compare commits

...

2 Commits

Author SHA1 Message Date
David Perez
4b283af983 🍒 PM-38587: Feat: Add accessibility service disclaimer at startup (#7019) 2026-06-04 18:11:09 -04:00
Patrick Honkonen
d0880d31f2 🍒Update MobilePremiumUpgrade feature flag key to lowercase (#6938) 2026-05-18 16:53:08 +00:00
13 changed files with 263 additions and 2 deletions

View File

@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.core.app.ActivityCompat
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -25,6 +26,8 @@ import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.platform.util.setHorizonOSAppLayout
import com.bitwarden.ui.platform.util.setupEdgeToEdge
@@ -93,6 +96,7 @@ class MainActivity : AppCompatActivity() {
mainViewModel.trySendAction(MainAction.PremiumCheckoutResult(it))
}
@Suppress("LongMethod")
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
var shouldShowSplashScreen = true
@@ -132,6 +136,14 @@ class MainActivity : AppCompatActivity() {
theme = state.theme,
dynamicColor = state.isDynamicColorsEnabled,
) {
MainActivityDialogs(
dialogState = state.dialogState,
onAccessibilityDisclaimerDismiss = {
mainViewModel.trySendAction(
MainAction.DismissAccessibilityDisclaimerDialog,
)
},
)
NavHost(
navController = navController,
startDestination = RootNavigationRoute,
@@ -223,6 +235,26 @@ class MainActivity : AppCompatActivity() {
}
}
@Composable
private fun MainActivityDialogs(
dialogState: MainState.DialogState?,
onAccessibilityDisclaimerDismiss: () -> Unit,
) {
when (dialogState) {
MainState.DialogState.AccessibilityDisclosure -> {
BitwardenBasicDialog(
title = stringResource(id = BitwardenString.accessibility_service_disclosure),
message = stringResource(
id = BitwardenString.accessibility_disclosure_start_up_text,
),
onDismissRequest = onAccessibilityDisclaimerDismiss,
)
}
null -> Unit
}
}
@Composable
private fun SetupEventsEffect(navController: NavController) {
EventsEffect(viewModel = mainViewModel) { event ->

View File

@@ -42,6 +42,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
@@ -99,6 +100,7 @@ class MainViewModel @Inject constructor(
isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed,
isDynamicColorsEnabled = settingsRepository.isDynamicColorsEnabled,
hasResizeBeenRequested = false,
dialogState = null,
),
) {
private var specialCircumstance: SpecialCircumstance?
@@ -149,6 +151,12 @@ class MainViewModel @Inject constructor(
.onEach(::trySendAction)
.launchIn(viewModelScope)
settingsRepository
.hasShownAccessibilityDisclaimerFlow
.map { MainAction.Internal.HasShownAccessibilityDisclaimerUpdate(it) }
.onEach(::trySendAction)
.launchIn(viewModelScope)
merge(
authRepository
.userStateFlow
@@ -201,6 +209,10 @@ class MainViewModel @Inject constructor(
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action)
is MainAction.PremiumCheckoutResult -> handlePremiumCheckoutResult(action)
is MainAction.DismissAccessibilityDisclaimerDialog -> {
handleDismissAccessibilityDisclaimerDialog()
}
is MainAction.Internal -> handleInternalAction(action)
}
}
@@ -224,6 +236,20 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
is MainAction.Internal.ResizeHasBeenRequested -> handleResizeHasBeenRequested()
is MainAction.Internal.HasShownAccessibilityDisclaimerUpdate -> {
handleHasShownAccessibilityDisclaimerUpdate(action)
}
}
}
private fun handleHasShownAccessibilityDisclaimerUpdate(
action: MainAction.Internal.HasShownAccessibilityDisclaimerUpdate,
) {
mutableStateFlow.update {
it.copy(
dialogState = MainState.DialogState.AccessibilityDisclosure
.takeUnless { action.hasBeenShown },
)
}
}
@@ -257,6 +283,10 @@ class MainViewModel @Inject constructor(
)
}
private fun handleDismissAccessibilityDisclaimerDialog() {
settingsRepository.accessibilityDisclaimerHasBeenShown()
}
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
when (val data = action.screenResumeData) {
null -> appResumeManager.clearResumeScreen()
@@ -538,12 +568,25 @@ data class MainState(
val isScreenCaptureAllowed: Boolean,
val isDynamicColorsEnabled: Boolean,
val hasResizeBeenRequested: Boolean,
val dialogState: DialogState?,
) : Parcelable {
/**
* Contains all feature flags that are available to the UI.
*/
val featureFlagsState: FeatureFlagsState
get() = FeatureFlagsState
/**
* Representation of all dialogs displayed from the [MainActivity].
*/
@Parcelize
sealed class DialogState : Parcelable {
/**
* Displays an accessibility disclosure to users explaining how we utilize the
* AccessibilityService.
*/
data object AccessibilityDisclosure : DialogState()
}
}
/**
@@ -605,6 +648,11 @@ sealed class MainAction {
*/
data class AppSpecificLanguageUpdate(val appLanguage: AppLanguage) : MainAction()
/**
* Received if the user dismisses the accessibility disclaimer dialog.
*/
data object DismissAccessibilityDisclaimerDialog : MainAction()
/**
* Actions for internal use by the ViewModel.
*/
@@ -660,6 +708,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()
}
}

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"
@@ -123,6 +124,8 @@ class SettingsDiskSourceImpl(
private val mutableIsDynamicColorsEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableHasShownAccessibilityDisclaimerFlow = bufferedMutableSharedFlow<Boolean?>()
init {
migrateScreenCaptureSetting()
}
@@ -162,6 +165,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) {
@@ -264,6 +278,7 @@ class SettingsDiskSourceImpl(
// - Premium upgrade banner dismissed
// - Upgraded to Premium action card consumed
// - Upgraded to Premium action card pending
// - Has shown accessibility disclaimer dialog
}
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =

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 = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
@@ -379,6 +389,10 @@ class SettingsRepositoryImpl(
.launchIn(unconfinedScope)
}
override fun accessibilityDisclaimerHasBeenShown() {
settingsDiskSource.hasShownAccessibilityDisclaimer = true
}
override fun disableAutofill() {
autofillManager.disableAutofillServices()

View File

@@ -113,6 +113,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
@@ -123,6 +124,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
@@ -1333,6 +1338,7 @@ class MainViewModelTest : BaseViewModelTest() {
isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed,
isDynamicColorsEnabled = settingsRepository.isDynamicColorsEnabled,
hasResizeBeenRequested = false,
dialogState = null,
)
viewModel.stateFlow.test {
assertEquals(
@@ -1349,6 +1355,49 @@ class MainViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on HasShownAccessibilityDisclaimerUpdate with false should show accessibility dialog`() =
runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableHasShownAccessibilityDisclaimerFlow.value = false
assertEquals(
DEFAULT_STATE.copy(
dialogState = MainState.DialogState.AccessibilityDisclosure,
),
awaitItem(),
)
}
}
@Test
fun `on HasShownAccessibilityDisclaimerUpdate with true should clear accessibility dialog`() =
runTest {
mutableHasShownAccessibilityDisclaimerFlow.value = false
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
dialogState = MainState.DialogState.AccessibilityDisclosure,
),
awaitItem(),
)
mutableHasShownAccessibilityDisclaimerFlow.value = true
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `on DismissAccessibilityDisclaimerDialog should store that the accessibility disclaimer has been shown`() {
val viewModel = createViewModel()
viewModel.trySendAction(MainAction.DismissAccessibilityDisclaimerDialog)
verify(exactly = 1) {
settingsRepository.accessibilityDisclaimerHasBeenShown()
}
}
private fun createViewModel(
initialSpecialCircumstance: SpecialCircumstance? = null,
) = MainViewModel(
@@ -1378,6 +1427,7 @@ private val DEFAULT_STATE: MainState = MainState(
isScreenCaptureAllowed = true,
isDynamicColorsEnabled = false,
hasResizeBeenRequested = false,
dialogState = null,
)
private val DEFAULT_FIRST_TIME_STATE = FirstTimeState(

View File

@@ -466,6 +466,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

@@ -92,6 +92,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 =
@@ -109,6 +110,8 @@ class FakeSettingsDiskSource(
private val mutableIsDynamicColorsEnabled =
bufferedMutableSharedFlow<Boolean?>()
private val mutableHasShownAccessibilityDisclaimerFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableVaultRegisteredForExportFlow =
bufferedMutableSharedFlow<Boolean?>()
@@ -158,6 +161,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

@@ -118,7 +118,7 @@ sealed class FlagKey<out T : Any> {
* Data object holding the feature flag key for the mobile Premium upgrade feature.
*/
data object MobilePremiumUpgrade : FlagKey<Boolean>() {
override val keyName: String = "PM-31697-premium-upgrade-path"
override val keyName: String = "pm-31697-premium-upgrade-path"
override val defaultValue: Boolean = false
}

View File

@@ -34,7 +34,7 @@ class FlagKeyTest {
)
assertEquals(
FlagKey.MobilePremiumUpgrade.keyName,
"PM-31697-premium-upgrade-path",
"pm-31697-premium-upgrade-path",
)
assertEquals(
FlagKey.AttachmentUpdates.keyName,

View File

@@ -615,6 +615,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, nor do we 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>