PM-11485: Add routing for accessibility autofill (#3895)

This commit is contained in:
David Perez 2024-09-11 11:53:30 -05:00 committed by GitHub
parent fe1f897d64
commit 6521848a8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 624 additions and 35 deletions

View file

@ -15,6 +15,7 @@ import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
@ -43,6 +44,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var autofillCompletionManager: AutofillCompletionManager
@Inject
lateinit var accessibilityCompletionManager: AccessibilityCompletionManager
@Inject
lateinit var settingsRepository: SettingsRepository
@ -74,6 +78,10 @@ class MainActivity : AppCompatActivity() {
val navController = rememberNavController()
EventsEffect(viewModel = mainViewModel) { event ->
when (event) {
is MainEvent.CompleteAccessibilityAutofill -> {
handleCompleteAccessibilityAutofill(event)
}
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
@ -130,6 +138,15 @@ class MainActivity : AppCompatActivity() {
mainViewModel.trySendAction(MainAction.OpenDebugMenu)
}
private fun handleCompleteAccessibilityAutofill(
event: MainEvent.CompleteAccessibilityAutofill,
) {
accessibilityCompletionManager.completeAccessibilityAutofill(
activity = this,
cipherView = event.cipherView,
)
}
private fun handleCompleteAutofill(event: MainEvent.CompleteAutofill) {
autofillCompletionManager.completeAutofill(
activity = this,

View file

@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull
@ -53,13 +54,14 @@ private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
private val fido2CredentialManager: Fido2CredentialManager,
private val intentManager: IntentManager,
settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
vaultRepository: VaultRepository,
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
private val savedStateHandle: SavedStateHandle,
@ -85,6 +87,12 @@ class MainViewModel @Inject constructor(
.onEach { specialCircumstance = it }
.launchIn(viewModelScope)
accessibilitySelectionManager
.accessibilitySelectionFlow
.map { MainAction.Internal.AccessibilitySelectionReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
autofillSelectionManager
.autofillSelectionFlow
.onEach { trySendAction(MainAction.Internal.AutofillSelectionReceive(it)) }
@ -151,6 +159,10 @@ class MainViewModel @Inject constructor(
override fun handleAction(action: MainAction) {
when (action) {
is MainAction.Internal.AccessibilitySelectionReceive -> {
handleAccessibilitySelectionReceive(action)
}
is MainAction.Internal.AutofillSelectionReceive -> {
handleAutofillSelectionReceive(action)
}
@ -169,6 +181,12 @@ class MainViewModel @Inject constructor(
sendEvent(MainEvent.NavigateToDebugMenu)
}
private fun handleAccessibilitySelectionReceive(
action: MainAction.Internal.AccessibilitySelectionReceive,
) {
sendEvent(MainEvent.CompleteAccessibilityAutofill(cipherView = action.cipherView))
}
private fun handleAutofillSelectionReceive(
action: MainAction.Internal.AutofillSelectionReceive,
) {
@ -332,11 +350,13 @@ class MainViewModel @Inject constructor(
),
)
}
EmailTokenResult.Expired -> {
specialCircumstanceManager.specialCircumstance = SpecialCircumstance
.RegistrationEvent
.ExpiredRegistrationLink
}
EmailTokenResult.Success -> {
if (authRepository.activeUserId != null) {
authRepository.hasPendingAccountAddition = true
@ -384,6 +404,14 @@ sealed class MainAction {
* Actions for internal use by the ViewModel.
*/
sealed class Internal : MainAction() {
/**
* Indicates the user has manually selected the given [cipherView] for accessibility
* autofill.
*/
data class AccessibilitySelectionReceive(
val cipherView: CipherView,
) : Internal()
/**
* Indicates the user has manually selected the given [cipherView] for autofill.
*/
@ -421,6 +449,12 @@ sealed class MainAction {
* Represents events that are emitted by the [MainViewModel].
*/
sealed class MainEvent {
/**
* Event indicating that the user has chosen the given [cipherView] for accessibility autofill
* and that the process is ready to complete.
*/
data class CompleteAccessibilityAutofill(val cipherView: CipherView) : MainEvent()
/**
* Event indicating that the user has chosen the given [cipherView] for autofill and that the
* process is ready to complete.

View file

@ -1,15 +1,23 @@
package com.x8bit.bitwarden.data.autofill.accessibility.di
import android.content.Context
import android.content.pm.PackageManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNameManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNameManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.parser.AccessibilityParser
import com.x8bit.bitwarden.data.autofill.accessibility.parser.AccessibilityParserImpl
import com.x8bit.bitwarden.data.autofill.manager.AutofillTotpManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
@ -20,6 +28,19 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AccessibilityModule {
@Singleton
@Provides
fun providesAccessibilityCompletionManager(
accessibilityAutofillManager: AccessibilityAutofillManager,
totpManager: AutofillTotpManager,
dispatcherManager: DispatcherManager,
): AccessibilityCompletionManager =
AccessibilityCompletionManagerImpl(
accessibilityAutofillManager = accessibilityAutofillManager,
totpManager = totpManager,
dispatcherManager = dispatcherManager,
)
@Singleton
@Provides
fun providesAccessibilityInvokeManager(): AccessibilityAutofillManager =
@ -29,6 +50,11 @@ object AccessibilityModule {
@Provides
fun providesAccessibilityParser(): AccessibilityParser = AccessibilityParserImpl()
@Singleton
@Provides
fun providesAccessibilitySelectionManager(): AccessibilitySelectionManager =
AccessibilitySelectionManagerImpl()
@Singleton
@Provides
fun providesLauncherPackageNameManager(
@ -39,4 +65,10 @@ object AccessibilityModule {
clockProvider = { clock },
packageManager = packageManager,
)
@Singleton
@Provides
fun providesPackageManager(
@ApplicationContext context: Context,
): PackageManager = context.packageManager
}

View file

@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
/**
* A relay manager used to notify the accessibility service to attempt an autofill.
*/
@ -8,5 +10,5 @@ interface AccessibilityAutofillManager {
* Indicates that the Autofill tile has been clicked and we attempt an accessibility-based
* autofill.
*/
var isAccessibilityTileClicked: Boolean
var accessibilityAction: AccessibilityAction?
}

View file

@ -1,8 +1,10 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
/**
* The default implementation for the [AccessibilityAutofillManager].
*/
class AccessibilityAutofillManagerImpl : AccessibilityAutofillManager {
override var isAccessibilityTileClicked: Boolean = false
override var accessibilityAction: AccessibilityAction? = null
}

View file

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.app.Activity
import com.bitwarden.vault.CipherView
/**
* A manager for completing the accessibility-based autofill process after the user has made a
* selection.
*/
interface AccessibilityCompletionManager {
/**
* Completes the accessibility-based autofill flow originating with the given [activity] using
* the selected [cipherView].
*/
fun completeAccessibilityAutofill(activity: Activity, cipherView: CipherView)
}

View file

@ -0,0 +1,53 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.app.Activity
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
import com.x8bit.bitwarden.data.autofill.accessibility.util.toUriOrNull
import com.x8bit.bitwarden.data.autofill.manager.AutofillTotpManager
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Default implementation for the [AccessibilityCompletionManager].
*/
class AccessibilityCompletionManagerImpl(
private val accessibilityAutofillManager: AccessibilityAutofillManager,
private val totpManager: AutofillTotpManager,
dispatcherManager: DispatcherManager,
) : AccessibilityCompletionManager {
private val mainScope = CoroutineScope(dispatcherManager.main)
override fun completeAccessibilityAutofill(activity: Activity, cipherView: CipherView) {
val autofillSelectionData = activity
.intent
?.getAutofillSelectionDataOrNull()
?: run {
activity.finish()
return
}
if (autofillSelectionData.framework != AutofillSelectionData.Framework.ACCESSIBILITY) {
activity.finish()
return
}
val uri = autofillSelectionData
.uri
?.toUriOrNull()
?: run {
activity.finish()
return
}
accessibilityAutofillManager.accessibilityAction = AccessibilityAction.AttemptFill(
cipherView = cipherView,
uri = uri,
)
mainScope.launch {
totpManager.tryCopyTotpToClipboard(cipherView = cipherView)
activity.finish()
}
}
}

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import com.bitwarden.vault.CipherView
import kotlinx.coroutines.flow.Flow
/**
* A manager class used to handle the accessibility autofill selections.
*/
interface AccessibilitySelectionManager {
/**
* Emits a [CipherView] as a result of calls to [emitAccessibilitySelection].
*/
val accessibilitySelectionFlow: Flow<CipherView>
/**
* Triggers an emission via [accessibilitySelectionFlow].
*/
fun emitAccessibilitySelection(cipherView: CipherView)
}

View file

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import com.bitwarden.vault.CipherView
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
/**
* The default implementation of the [AccessibilitySelectionManager].
*/
class AccessibilitySelectionManagerImpl : AccessibilitySelectionManager {
private val accessibilitySelectionChannel: Channel<CipherView> = Channel(
capacity = Int.MAX_VALUE,
)
override val accessibilitySelectionFlow: Flow<CipherView> =
accessibilitySelectionChannel.receiveAsFlow()
override fun emitAccessibilitySelection(cipherView: CipherView) {
accessibilitySelectionChannel.trySend(cipherView)
}
}

View file

@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.autofill.accessibility.model
import android.net.Uri
import com.bitwarden.vault.CipherView
/**
*Represents an action to be taken by the accessibility service.
*/
sealed class AccessibilityAction {
/**
* Indicates that the accessibility service should attempt to scan the currently foregrounded
* application for a [Uri].
*/
data object AttemptParseUri : AccessibilityAction()
/**
* Indicates that the accessibility service should attempt to scan the currently foregrounded
* application for a fields to fill.
*/
data class AttemptFill(
val cipherView: CipherView,
val uri: Uri,
) : AccessibilityAction()
}

View file

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.autofill.accessibility.util
import android.net.Uri
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import java.net.URISyntaxException
/**
* Attempts to parse a [Uri] from a string and returns null if an error occurs.
*/
@OmitFromCoverage
fun String.toUriOrNull(): Uri? =
try {
Uri.parse(this)
} catch (e: URISyntaxException) {
null
}

View file

@ -6,11 +6,13 @@ import kotlinx.parcelize.Parcelize
/**
* Represents data for a manual autofill selection.
*
* @property framework The framework being used to fulfill this autofill request.
* @property type The type of autofill selection that must be made.
* @property uri A URI representing the location where data should be filled (if available).
*/
@Parcelize
data class AutofillSelectionData(
val framework: Framework,
val type: Type,
val uri: String?,
) : Parcelable {
@ -22,4 +24,12 @@ data class AutofillSelectionData(
CARD,
LOGIN,
}
/**
* The type of framework to use with this autofill.
*/
enum class Framework {
ACCESSIBILITY,
AUTOFILL,
}
}

View file

@ -30,18 +30,21 @@ private const val AUTOFILL_BUNDLE_KEY = "autofill-bundle-key"
*/
fun createAutofillSelectionIntent(
context: Context,
framework: AutofillSelectionData.Framework,
type: AutofillSelectionData.Type,
uri: String?,
): Intent =
Intent(
context,
MainActivity::class.java,
)
Intent(context, MainActivity::class.java)
.apply {
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra(
AUTOFILL_BUNDLE_KEY,
bundleOf(
AUTOFILL_SELECTION_DATA_KEY to AutofillSelectionData(type = type, uri = uri),
AUTOFILL_SELECTION_DATA_KEY to AutofillSelectionData(
framework = framework,
type = type,
uri = uri,
),
),
)
}

View file

@ -34,6 +34,7 @@ fun FilledData.buildVaultItemDataset(
): Dataset {
val intent = createAutofillSelectionIntent(
context = autofillAppInfo.context,
framework = AutofillSelectionData.Framework.AUTOFILL,
type = when (this.originalPartition) {
is AutofillPartition.Card -> AutofillSelectionData.Type.CARD
is AutofillPartition.Login -> AutofillSelectionData.Type.LOGIN

View file

@ -8,6 +8,7 @@ import android.service.quicksettings.TileService
import androidx.annotation.Keep
import com.x8bit.bitwarden.AccessibilityActivity
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManager
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import dagger.hilt.android.AndroidEntryPoint
@ -33,7 +34,7 @@ class BitwardenAutofillTileService : TileService() {
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun launchAutofill() {
accessibilityAutofillManager.isAccessibilityTileClicked = true
accessibilityAutofillManager.accessibilityAction = AccessibilityAction.AttemptParseUri
val intent = Intent(applicationContext, AccessibilityActivity::class.java)
if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) {
@Suppress("DEPRECATION")

View file

@ -10,6 +10,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
@ -29,6 +30,7 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.manager.util.toFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.platform.manager.util.toFido2RequestOrNull
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
@ -92,6 +94,7 @@ class VaultItemListingViewModel @Inject constructor(
private val vaultRepository: VaultRepository,
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
private val accessibilitySelectionManager: AccessibilitySelectionManager,
private val autofillSelectionManager: AutofillSelectionManager,
private val cipherMatchingManager: CipherMatchingManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
@ -104,7 +107,6 @@ class VaultItemListingViewModel @Inject constructor(
val activeAccountSummary = userState.toActiveAccountSummary()
val accountSummaries = userState.toAccountSummaries()
val specialCircumstance = specialCircumstanceManager.specialCircumstance
val autofillSelectionData = specialCircumstance as? SpecialCircumstance.AutofillSelection
val fido2CreationData = specialCircumstance as? SpecialCircumstance.Fido2Save
val fido2AssertionData = specialCircumstance as? SpecialCircumstance.Fido2Assertion
val fido2GetCredentialsData =
@ -127,7 +129,7 @@ class VaultItemListingViewModel @Inject constructor(
policyDisablesSend = policyManager
.getActivePolicies(type = PolicyTypeJson.DISABLE_SEND)
.any(),
autofillSelectionData = autofillSelectionData?.autofillSelectionData,
autofillSelectionData = specialCircumstance?.toAutofillSelectionDataOrNull(),
hasMasterPassword = userState.activeAccount.hasMasterPassword,
fido2CredentialRequest = fido2CreationData?.fido2CredentialRequest,
fido2CredentialAssertionRequest = fido2AssertionData?.fido2AssertionRequest,
@ -547,9 +549,19 @@ class VaultItemListingViewModel @Inject constructor(
}
private fun handleItemClick(action: VaultItemListingsAction.ItemClick) {
if (state.isAutofill) {
val cipherView = getCipherViewOrNull(action.id) ?: return
autofillSelectionManager.emitAutofillSelection(cipherView = cipherView)
state.autofillSelectionData?.let { autofillSelectionData ->
val cipherView = getCipherViewOrNull(cipherId = action.id) ?: return
when (autofillSelectionData.framework) {
AutofillSelectionData.Framework.ACCESSIBILITY -> {
accessibilitySelectionManager.emitAccessibilitySelection(
cipherView = cipherView,
)
}
AutofillSelectionData.Framework.AUTOFILL -> {
autofillSelectionManager.emitAutofillSelection(cipherView = cipherView)
}
}
return
}

View file

@ -11,6 +11,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManagerImpl
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
@ -70,6 +72,8 @@ import java.time.ZoneOffset
class MainViewModelTest : BaseViewModelTest() {
private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl()
private val accessibilitySelectionManager: AccessibilitySelectionManager =
AccessibilitySelectionManagerImpl()
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT)
private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true)
@ -221,6 +225,20 @@ class MainViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `accessibility selection updates should emit CompleteAccessibilityAutofill events`() =
runTest {
val viewModel = createViewModel()
val cipherView = mockk<CipherView>()
viewModel.eventFlow.test {
accessibilitySelectionManager.emitAccessibilitySelection(cipherView = cipherView)
assertEquals(
MainEvent.CompleteAccessibilityAutofill(cipherView = cipherView),
awaitItem(),
)
}
}
@Test
fun `autofill selection updates should emit CompleteAutofill events`() = runTest {
val viewModel = createViewModel()
@ -964,6 +982,7 @@ class MainViewModelTest : BaseViewModelTest() {
private fun createViewModel(
initialSpecialCircumstance: SpecialCircumstance? = null,
) = MainViewModel(
accessibilitySelectionManager = accessibilitySelectionManager,
autofillSelectionManager = autofillSelectionManager,
specialCircumstanceManager = specialCircumstanceManager,
garbageCollectionManager = garbageCollectionManager,

View file

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class AccessibilityAutofillManagerTest {
private val accessibilityAutofillManager: AccessibilityAutofillManager =
AccessibilityAutofillManagerImpl()
@Test
fun `isAccessibilityTileClicked should simply hold the state it is provided`() {
val attemptParseUri = AccessibilityAction.AttemptParseUri
val attemptFill = AccessibilityAction.AttemptFill(cipherView = mockk(), uri = mockk())
assertNull(accessibilityAutofillManager.accessibilityAction)
accessibilityAutofillManager.accessibilityAction = attemptParseUri
assertEquals(attemptParseUri, accessibilityAutofillManager.accessibilityAction)
accessibilityAutofillManager.accessibilityAction = attemptFill
assertEquals(attemptFill, accessibilityAutofillManager.accessibilityAction)
accessibilityAutofillManager.accessibilityAction = null
assertNull(accessibilityAutofillManager.accessibilityAction)
}
}

View file

@ -0,0 +1,210 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import android.app.Activity
import android.content.Intent
import android.net.Uri
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
import com.x8bit.bitwarden.data.autofill.accessibility.util.toUriOrNull
import com.x8bit.bitwarden.data.autofill.manager.AutofillTotpManager
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AccessibilityCompletionManagerTest {
private val activity: Activity = mockk {
every { finish() } just runs
}
private val accessibilityAutofillManager: AccessibilityAutofillManager = mockk()
private val totpManager: AutofillTotpManager = mockk()
private val fakeDispatcherManager: FakeDispatcherManager = FakeDispatcherManager()
private val accessibilityCompletionManager: AccessibilityCompletionManager =
AccessibilityCompletionManagerImpl(
accessibilityAutofillManager = accessibilityAutofillManager,
totpManager = totpManager,
dispatcherManager = fakeDispatcherManager,
)
@BeforeEach
fun setup() {
fakeDispatcherManager.setMain(fakeDispatcherManager.unconfined)
mockkStatic(
Intent::getAutofillSelectionDataOrNull,
String::toUriOrNull,
)
}
@AfterEach
fun tearDown() {
fakeDispatcherManager.resetMain()
unmockkStatic(
Intent::getAutofillSelectionDataOrNull,
String::toUriOrNull,
)
}
@Test
fun `completeAccessibilityAutofill when there is no Intent should finish the Activity`() {
every { activity.intent } returns null
accessibilityCompletionManager.completeAccessibilityAutofill(
activity = activity,
cipherView = mockk(),
)
verify(exactly = 1) {
activity.intent
activity.finish()
}
}
@Suppress("MaxLineLength")
@Test
fun `completeAccessibilityAutofill when there is no AutofillSelectionData should finish the Activity`() {
val mockIntent: Intent = mockk()
every { activity.intent } returns mockIntent
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
accessibilityCompletionManager.completeAccessibilityAutofill(
activity = activity,
cipherView = mockk(),
)
verify(exactly = 1) {
activity.intent
mockIntent.getAutofillSelectionDataOrNull()
activity.finish()
}
}
@Suppress("MaxLineLength")
@Test
fun `completeAccessibilityAutofill when the AutofillSelectionData is for the incorrect framework should finish the Activity`() {
val mockIntent: Intent = mockk()
every { activity.intent } returns mockIntent
val selectionData = AutofillSelectionData(
framework = AutofillSelectionData.Framework.AUTOFILL,
type = AutofillSelectionData.Type.LOGIN,
uri = "",
)
every { mockIntent.getAutofillSelectionDataOrNull() } returns selectionData
accessibilityCompletionManager.completeAccessibilityAutofill(
activity = activity,
cipherView = mockk(),
)
verify(exactly = 1) {
activity.intent
mockIntent.getAutofillSelectionDataOrNull()
activity.finish()
}
}
@Suppress("MaxLineLength")
@Test
fun `completeAccessibilityAutofill when the AutofillSelectionData is missing uri should finish the Activity`() {
val mockIntent: Intent = mockk()
every { activity.intent } returns mockIntent
val selectionData = AutofillSelectionData(
framework = AutofillSelectionData.Framework.ACCESSIBILITY,
type = AutofillSelectionData.Type.LOGIN,
uri = null,
)
every { mockIntent.getAutofillSelectionDataOrNull() } returns selectionData
accessibilityCompletionManager.completeAccessibilityAutofill(
activity = activity,
cipherView = mockk(),
)
verify(exactly = 1) {
activity.intent
mockIntent.getAutofillSelectionDataOrNull()
activity.finish()
}
}
@Suppress("MaxLineLength")
@Test
fun `completeAccessibilityAutofill when the AutofillSelectionData contains an invalid uri should finish the Activity`() {
val mockIntent: Intent = mockk()
every { activity.intent } returns mockIntent
val stringUri = "invalid uri"
every { stringUri.toUriOrNull() } returns null
val selectionData = AutofillSelectionData(
framework = AutofillSelectionData.Framework.ACCESSIBILITY,
type = AutofillSelectionData.Type.LOGIN,
uri = stringUri,
)
every { mockIntent.getAutofillSelectionDataOrNull() } returns selectionData
accessibilityCompletionManager.completeAccessibilityAutofill(
activity = activity,
cipherView = mockk(),
)
verify(exactly = 1) {
activity.intent
mockIntent.getAutofillSelectionDataOrNull()
activity.finish()
}
}
@Suppress("MaxLineLength")
@Test
fun `completeAccessibilityAutofill when the AutofillSelectionData is correct should set the accessibility action, copy the totp and finish the activity`() {
val cipherView: CipherView = mockk()
val mockIntent: Intent = mockk()
every { activity.intent } returns mockIntent
val stringUri = "androidapp://com.x8bit.bitwarden"
val uri: Uri = mockk()
every { stringUri.toUriOrNull() } returns uri
val selectionData = AutofillSelectionData(
framework = AutofillSelectionData.Framework.ACCESSIBILITY,
type = AutofillSelectionData.Type.LOGIN,
uri = stringUri,
)
every { mockIntent.getAutofillSelectionDataOrNull() } returns selectionData
every {
accessibilityAutofillManager.accessibilityAction = AccessibilityAction.AttemptFill(
cipherView = cipherView,
uri = uri,
)
} just runs
coEvery { totpManager.tryCopyTotpToClipboard(cipherView = cipherView) } just runs
accessibilityCompletionManager.completeAccessibilityAutofill(
activity = activity,
cipherView = cipherView,
)
verify(exactly = 1) {
activity.intent
mockIntent.getAutofillSelectionDataOrNull()
accessibilityAutofillManager.accessibilityAction = AccessibilityAction.AttemptFill(
cipherView = cipherView,
uri = uri,
)
activity.finish()
}
coVerify(exactly = 1) {
totpManager.tryCopyTotpToClipboard(cipherView = cipherView)
}
}
}

View file

@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.autofill.accessibility.manager
import app.cash.turbine.test
import com.bitwarden.vault.CipherView
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class AccessibilitySelectionManagerTest {
private val accessibilitySelectionManager: AccessibilitySelectionManager =
AccessibilitySelectionManagerImpl()
@Test
fun `autofillSelectionFlow should emit whenever emitAutofillSelection is called`() =
runTest {
accessibilitySelectionManager.accessibilitySelectionFlow.test {
expectNoEvents()
val cipherView1 = mockk<CipherView>()
accessibilitySelectionManager.emitAccessibilitySelection(cipherView = cipherView1)
assertEquals(cipherView1, awaitItem())
val cipherView2 = mockk<CipherView>()
accessibilitySelectionManager.emitAccessibilitySelection(cipherView = cipherView2)
assertEquals(cipherView2, awaitItem())
}
}
}

View file

@ -1,21 +0,0 @@
package com.x8bit.bitwarden.data.autofill.manager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManagerImpl
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class AccessibilityAutofillManagerTest {
private val accessibilityAutofillManager: AccessibilityAutofillManager =
AccessibilityAutofillManagerImpl()
@Test
fun `isAccessibilityTileClicked should simply hold the state it is provided`() {
assertFalse(accessibilityAutofillManager.isAccessibilityTileClicked)
accessibilityAutofillManager.isAccessibilityTileClicked = true
assertTrue(accessibilityAutofillManager.isAccessibilityTileClicked)
accessibilityAutofillManager.isAccessibilityTileClicked = false
assertFalse(accessibilityAutofillManager.isAccessibilityTileClicked)
}
}

View file

@ -63,6 +63,7 @@ class SpecialCircumstanceExtensionsTest {
fun `toAutofillSelectionDataOrNull should return a non-null value for AutofillSelection`() {
val autofillSelectionData = AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
framework = AutofillSelectionData.Framework.AUTOFILL,
uri = "uri",
)
assertEquals(

View file

@ -489,6 +489,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
fun `when the active user has an unlocked vault but there is an AutofillSelection special circumstance the nav state should be VaultUnlockedForAutofillSelection`() {
val autofillSelectionData = AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
framework = AutofillSelectionData.Framework.AUTOFILL,
uri = "uri",
)
specialCircumstanceManager.specialCircumstance =

View file

@ -1435,6 +1435,7 @@ private const val CIPHER_ID = "mockId-1"
private val AUTOFILL_SELECTION_DATA =
AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
framework = AutofillSelectionData.Framework.AUTOFILL,
uri = AUTOFILL_URI,
)

View file

@ -263,6 +263,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
fun `initial add state should be correct when autofill selection`() = runTest {
val autofillSelectionData = AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
framework = AutofillSelectionData.Framework.AUTOFILL,
uri = "https://www.test.com",
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.AutofillSelection(

View file

@ -32,6 +32,7 @@ class AutofillSelectionDataExtensionsTest {
),
AutofillSelectionData(
type = AutofillSelectionData.Type.CARD,
framework = AutofillSelectionData.Framework.AUTOFILL,
uri = null,
)
.toDefaultAddTypeContent(isIndividualVaultDisabled = false),
@ -60,6 +61,7 @@ class AutofillSelectionDataExtensionsTest {
),
AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
framework = AutofillSelectionData.Framework.AUTOFILL,
uri = "https://www.test.com",
)
.toDefaultAddTypeContent(isIndividualVaultDisabled = true),

View file

@ -2041,6 +2041,7 @@ private val ACCOUNT_SUMMARIES = listOf(
private val AUTOFILL_SELECTION_DATA =
AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
framework = AutofillSelectionData.Framework.AUTOFILL,
uri = "https:://www.test.com",
)

View file

@ -13,6 +13,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManagerImpl
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
@ -93,6 +95,8 @@ import java.time.ZoneOffset
@Suppress("LargeClass")
class VaultItemListingViewModelTest : BaseViewModelTest() {
private val accessibilitySelectionManager: AccessibilitySelectionManager =
AccessibilitySelectionManagerImpl()
private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl()
private var mockFilteredCiphers: List<CipherView>? = null
@ -309,6 +313,49 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `ItemClick for vault item when accessibility autofill should post to the accessibilitySelectionManager`() =
runTest {
setupMockUri()
val cipherView = createMockCipherView(
number = 1,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
coEvery {
vaultRepository.getDecryptedFido2CredentialAutofillViews(
cipherViewList = listOf(cipherView),
)
} returns DecryptFido2CredentialAutofillViewResult.Error
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.AutofillSelection(
autofillSelectionData = AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
framework = AutofillSelectionData.Framework.ACCESSIBILITY,
uri = "https://www.test.com",
),
shouldFinishWhenComplete = true,
)
mutableVaultDataStateFlow.value = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(cipherView),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
)
val viewModel = createVaultItemListingViewModel()
accessibilitySelectionManager.accessibilitySelectionFlow.test {
viewModel.trySendAction(VaultItemListingsAction.ItemClick(id = "mockId-1"))
assertEquals(cipherView, awaitItem())
}
coVerify(exactly = 1) {
vaultRepository.getDecryptedFido2CredentialAutofillViews(
cipherViewList = listOf(cipherView),
)
}
}
@Test
fun `ItemClick for vault item when autofill should post to the AutofillSelectionManager`() =
runTest {
@ -326,6 +373,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
SpecialCircumstance.AutofillSelection(
autofillSelectionData = AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
framework = AutofillSelectionData.Framework.AUTOFILL,
uri = "https://www.test.com",
),
shouldFinishWhenComplete = true,
@ -1248,6 +1296,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
val autofillSelectionData = AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
framework = AutofillSelectionData.Framework.AUTOFILL,
uri = "https://www.test.com",
)
specialCircumstanceManager.specialCircumstance =
@ -3707,6 +3756,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
vaultRepository = vaultRepository,
environmentRepository = environmentRepository,
settingsRepository = settingsRepository,
accessibilitySelectionManager = accessibilitySelectionManager,
autofillSelectionManager = autofillSelectionManager,
cipherMatchingManager = cipherMatchingManager,
specialCircumstanceManager = specialCircumstanceManager,

View file

@ -488,6 +488,7 @@ class VaultItemListingDataExtensionsTest {
isIconLoadingDisabled = false,
autofillSelectionData = AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
framework = AutofillSelectionData.Framework.AUTOFILL,
uri = null,
),
fido2CreationData = null,
@ -572,6 +573,7 @@ class VaultItemListingDataExtensionsTest {
isIconLoadingDisabled = false,
autofillSelectionData = AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
framework = AutofillSelectionData.Framework.AUTOFILL,
uri = null,
),
fido2CreationData = null,
@ -694,6 +696,7 @@ class VaultItemListingDataExtensionsTest {
isIconLoadingDisabled = false,
autofillSelectionData = AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
framework = AutofillSelectionData.Framework.AUTOFILL,
uri = "https://www.test.com",
),
fido2CreationData = null,