diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt index eb40e0c8fd..3e789b444d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt @@ -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 @@ -110,6 +113,7 @@ class MainActivity : AppCompatActivity() { ) } + @Suppress("LongMethod") override fun onCreate(savedInstanceState: Bundle?) { intent = intent.validate() var shouldShowSplashScreen = true @@ -143,6 +147,14 @@ class MainActivity : AppCompatActivity() { theme = state.theme, dynamicColor = state.isDynamicColorsEnabled, ) { + MainActivityDialogs( + dialogState = state.dialogState, + onAccessibilityDisclaimerDismiss = { + mainViewModel.trySendAction( + MainAction.DismissAccessibilityDisclaimerDialog, + ) + }, + ) NavHost( navController = navController, startDestination = RootNavigationRoute, @@ -238,6 +250,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 -> diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt index b6c3d1ba87..d6930ee99c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt @@ -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 @@ -101,6 +102,7 @@ class MainViewModel @Inject constructor( isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed, isDynamicColorsEnabled = settingsRepository.isDynamicColorsEnabled, hasResizeBeenRequested = false, + dialogState = null, ), ) { private var specialCircumstance: SpecialCircumstance? @@ -151,6 +153,12 @@ class MainViewModel @Inject constructor( .onEach(::trySendAction) .launchIn(viewModelScope) + settingsRepository + .hasShownAccessibilityDisclaimerFlow + .map { MainAction.Internal.HasShownAccessibilityDisclaimerUpdate(it) } + .onEach(::trySendAction) + .launchIn(viewModelScope) + merge( authRepository .userStateFlow @@ -211,6 +219,10 @@ class MainViewModel @Inject constructor( is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action) is MainAction.PremiumCheckoutResult -> handlePremiumCheckoutResult(action) is MainAction.StripePortalResult -> handleStripePortalResult() + is MainAction.DismissAccessibilityDisclaimerDialog -> { + handleDismissAccessibilityDisclaimerDialog() + } + is MainAction.Internal -> handleInternalAction(action) } } @@ -235,6 +247,20 @@ 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, + ) { + mutableStateFlow.update { + it.copy( + dialogState = MainState.DialogState.AccessibilityDisclosure + .takeUnless { action.hasBeenShown }, + ) } } @@ -272,6 +298,10 @@ class MainViewModel @Inject constructor( specialCircumstanceManager.specialCircumstance = SpecialCircumstance.StripePortal } + private fun handleDismissAccessibilityDisclaimerDialog() { + settingsRepository.accessibilityDisclaimerHasBeenShown() + } + private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) { when (val data = action.screenResumeData) { null -> appResumeManager.clearResumeScreen() @@ -557,12 +587,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() + } } /** @@ -632,6 +675,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. */ @@ -692,6 +740,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() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt index e1231c09a8..cbaf366a29 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt @@ -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 + /** * The currently persisted app theme (or `null` if not set). */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index da19488a72..7d67a37810 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -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() + private val mutableHasShownAccessibilityDisclaimerFlow = bufferedMutableSharedFlow() + 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 + 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? = diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt index fc3f57b92b..67a7a9f796 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt @@ -187,6 +187,16 @@ interface SettingsRepository : FlightRecorderManager { */ val isScreenCaptureAllowedStateFlow: StateFlow + /** + * Whether the accessibility disclaimer has been displayed to the user. + */ + val hasShownAccessibilityDisclaimerFlow: StateFlow + + /** + * Stores that the accessibility disclaimer has been displayed to the user. + */ + fun accessibilityDisclaimerHasBeenShown() + /** * Disables autofill if it is currently enabled. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index 37c3045889..40a076a8a8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -372,6 +372,16 @@ class SettingsRepositoryImpl( initialValue = isScreenCaptureAllowed, ) + override val hasShownAccessibilityDisclaimerFlow: StateFlow + 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() diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt index c8eb470bb3..89c6d76b3f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt @@ -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 { 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 { every { activeUserId } returns DEFAULT_USER_STATE.activeUserId @@ -1369,6 +1374,7 @@ class MainViewModelTest : BaseViewModelTest() { isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed, isDynamicColorsEnabled = settingsRepository.isDynamicColorsEnabled, hasResizeBeenRequested = false, + dialogState = null, ) viewModel.stateFlow.test { assertEquals( @@ -1385,6 +1391,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 = MainViewModel( @@ -1415,6 +1464,7 @@ private val DEFAULT_STATE: MainState = MainState( isScreenCaptureAllowed = true, isDynamicColorsEnabled = false, hasResizeBeenRequested = false, + dialogState = null, ) private val DEFAULT_FIRST_TIME_STATE = FirstTimeState( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt index b07b119cc5..165419c528 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt @@ -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" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt index 241a0590c0..48f992c905 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt @@ -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() + private val mutableHasShownAccessibilityDisclaimerFlow = bufferedMutableSharedFlow() + private val mutableVaultRegisteredForExportFlow = bufferedMutableSharedFlow() @@ -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 + get() = mutableHasShownAccessibilityDisclaimerFlow.onSubscription { + emit(hasShownAccessibilityDisclaimer) + } + override var screenCaptureAllowed: Boolean? get() = storedScreenCaptureAllowed set(value) { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index ed96adb732..c96becdaad 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -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 diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 5d03ddcce6..ccf2223556 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -612,6 +612,7 @@ select Add TOTP to store the key safely Generate an email alias with an external forwarding service. Accessibility Service Disclosure 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. + 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, nor do we control any on-screen elements beyond credential entry. Accept Decline Login request has already expired.