BIT-2293, BIT-2294: Add vault and generator shortcuts (#1306)

This commit is contained in:
David Perez 2024-04-25 15:01:13 -05:00 committed by Álison Fernandes
parent 80f6011571
commit bb1fd4ae4f
13 changed files with 336 additions and 11 deletions

View file

@ -33,6 +33,11 @@
android:launchMode="@integer/launchModeAPIlevel"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -15,6 +15,8 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -116,6 +118,8 @@ class MainViewModel @Inject constructor(
val autofillSaveItem = intent.getAutofillSaveItemOrNull()
val autofillSelectionData = intent.getAutofillSelectionDataOrNull()
val shareData = intentManager.getShareDataFromIntent(intent)
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
val hasVaultShortcut = intent.isMyVaultShortcut
when {
passwordlessRequestData != null -> {
specialCircumstanceManager.specialCircumstance =
@ -153,6 +157,15 @@ class MainViewModel @Inject constructor(
shouldFinishWhenComplete = isFirstIntent,
)
}
hasGeneratorShortcut -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.GeneratorShortcut
}
hasVaultShortcut -> {
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.VaultShortcut
}
}
}
}

View file

@ -46,4 +46,16 @@ sealed class SpecialCircumstance : Parcelable {
val passwordlessRequestData: PasswordlessRequestData,
val shouldFinishWhenComplete: Boolean,
) : SpecialCircumstance()
/**
* The app was launched via deeplink to the generator.
*/
@Parcelize
data object GeneratorShortcut : SpecialCircumstance()
/**
* The app was launched via deeplink to the vault.
*/
@Parcelize
data object VaultShortcut : SpecialCircumstance()
}

View file

@ -13,6 +13,8 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
is SpecialCircumstance.AutofillSelection -> null
is SpecialCircumstance.PasswordlessRequest -> null
is SpecialCircumstance.ShareNewSend -> null
SpecialCircumstance.GeneratorShortcut -> null
SpecialCircumstance.VaultShortcut -> null
}
/**
@ -24,4 +26,6 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
is SpecialCircumstance.AutofillSelection -> this.autofillSelectionData
is SpecialCircumstance.PasswordlessRequest -> null
is SpecialCircumstance.ShareNewSend -> null
SpecialCircumstance.GeneratorShortcut -> null
SpecialCircumstance.VaultShortcut -> null
}

View file

@ -59,7 +59,6 @@ class RootNavViewModel @Inject constructor(
action: RootNavAction.Internal.UserStateUpdateReceive,
) {
val userState = action.userState
val specialCircumstance = action.specialCircumstance
val updatedRootNavState = when {
userState?.activeAccount?.trustedDevice?.isDeviceTrusted == false &&
!userState.activeAccount.isVaultUnlocked -> RootNavState.TrustedDevice
@ -73,7 +72,7 @@ class RootNavViewModel @Inject constructor(
userState.hasPendingAccountAddition -> RootNavState.Auth
userState.activeAccount.isVaultUnlocked -> {
when (specialCircumstance) {
when (val specialCircumstance = action.specialCircumstance) {
is SpecialCircumstance.AutofillSave -> {
RootNavState.VaultUnlockedForAutofillSave(
autofillSaveItem = specialCircumstance.autofillSaveItem,
@ -93,11 +92,10 @@ class RootNavViewModel @Inject constructor(
RootNavState.VaultUnlockedForAuthRequest
}
null -> {
RootNavState.VaultUnlocked(
activeUserId = userState.activeAccount.userId,
)
}
SpecialCircumstance.GeneratorShortcut,
SpecialCircumstance.VaultShortcut,
null,
-> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId)
}
}

View file

@ -1,6 +1,8 @@
package com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@ -11,10 +13,25 @@ import javax.inject.Inject
@HiltViewModel
class VaultUnlockedNavBarViewModel @Inject constructor(
private val authRepository: AuthRepository,
) :
BaseViewModel<Unit, VaultUnlockedNavBarEvent, VaultUnlockedNavBarAction>(
initialState = Unit,
) {
specialCircumstancesManager: SpecialCircumstanceManager,
) : BaseViewModel<Unit, VaultUnlockedNavBarEvent, VaultUnlockedNavBarAction>(
initialState = Unit,
) {
init {
when (specialCircumstancesManager.specialCircumstance) {
SpecialCircumstance.GeneratorShortcut -> {
sendEvent(VaultUnlockedNavBarEvent.NavigateToGeneratorScreen)
specialCircumstancesManager.specialCircumstance = null
}
SpecialCircumstance.VaultShortcut -> {
sendEvent(VaultUnlockedNavBarEvent.NavigateToVaultScreen)
specialCircumstancesManager.specialCircumstance = null
}
else -> Unit
}
}
override fun handleAction(action: VaultUnlockedNavBarAction) {
when (action) {

View file

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.ui.platform.util
import android.content.Intent
/**
* Returns `true` if the [Intent] is a deeplink to the vault, `false` otherwise.
*/
val Intent.isMyVaultShortcut: Boolean
get() = dataString?.equals("bitwarden://my_vault") == true
/**
* Returns `true` if the [Intent] is a deeplink to the password generator, `false` otherwise.
*/
val Intent.isPasswordGeneratorShortcut: Boolean
get() = dataString?.equals("bitwarden://password_generator") == true

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:enabled="true"
android:icon="@drawable/ic_generator"
android:shortcutId="bitwarden_password_generator"
android:shortcutLongLabel="@string/password_generator"
android:shortcutShortLabel="@string/password_generator">
<intent
android:action="android.intent.action.VIEW"
android:data="bitwarden://password_generator"
android:targetClass="com.x8bit.bitwarden.MainActivity"
android:targetPackage="com.x8bit.bitwarden.dev" />
</shortcut>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_vault"
android:shortcutId="bitwarden_my_vault"
android:shortcutLongLabel="@string/my_vault"
android:shortcutShortLabel="@string/my_vault">
<intent
android:action="android.intent.action.VIEW"
android:data="bitwarden://my_vault"
android:targetClass="com.x8bit.bitwarden.MainActivity"
android:targetPackage="com.x8bit.bitwarden.dev" />
</shortcut>
</shortcuts>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:enabled="true"
android:icon="@drawable/ic_generator"
android:shortcutId="bitwarden_password_generator"
android:shortcutLongLabel="@string/password_generator"
android:shortcutShortLabel="@string/password_generator">
<intent
android:action="android.intent.action.VIEW"
android:data="bitwarden://password_generator"
android:targetClass="com.x8bit.bitwarden.MainActivity"
android:targetPackage="com.x8bit.bitwarden" />
</shortcut>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_vault"
android:shortcutId="bitwarden_my_vault"
android:shortcutLongLabel="@string/my_vault"
android:shortcutShortLabel="@string/my_vault">
<intent
android:action="android.intent.action.VIEW"
android:data="bitwarden://my_vault"
android:targetClass="com.x8bit.bitwarden.MainActivity"
android:targetPackage="com.x8bit.bitwarden" />
</shortcut>
</shortcuts>

View file

@ -18,6 +18,8 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@ -54,6 +56,10 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::getAutofillSaveItemOrNull,
Intent::getAutofillSelectionDataOrNull,
)
mockkStatic(
Intent::isMyVaultShortcut,
Intent::isPasswordGeneratorShortcut,
)
}
@AfterEach
@ -63,6 +69,10 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::getAutofillSaveItemOrNull,
Intent::getAutofillSelectionDataOrNull,
)
unmockkStatic(
Intent::isMyVaultShortcut,
Intent::isPasswordGeneratorShortcut,
)
}
@Suppress("MaxLineLength")
@ -150,6 +160,8 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
@ -175,6 +187,8 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns autofillSelectionData
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
@ -200,6 +214,8 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getAutofillSaveItemOrNull() } returns autofillSaveItem
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
@ -226,6 +242,8 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
@ -251,6 +269,8 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
viewModel.trySendAction(
MainAction.ReceiveNewIntent(
@ -276,6 +296,8 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns autofillSelectionData
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
viewModel.trySendAction(
MainAction.ReceiveNewIntent(
@ -301,6 +323,8 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getAutofillSaveItemOrNull() } returns autofillSaveItem
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
viewModel.trySendAction(
MainAction.ReceiveNewIntent(
@ -327,6 +351,8 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
viewModel.trySendAction(
MainAction.ReceiveNewIntent(
@ -342,6 +368,54 @@ class MainViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveNewIntent with a Vault deeplink data should set the special circumstance to VaultShortcut`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent> {
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { isMyVaultShortcut } returns true
every { isPasswordGeneratorShortcut } returns false
}
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
viewModel.trySendAction(
MainAction.ReceiveNewIntent(
intent = mockIntent,
),
)
assertEquals(
SpecialCircumstance.VaultShortcut,
specialCircumstanceManager.specialCircumstance,
)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveNewIntent with a password generator deeplink data should set the special circumstance to GeneratorShortcut`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent> {
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns true
}
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
viewModel.trySendAction(
MainAction.ReceiveNewIntent(
intent = mockIntent,
),
)
assertEquals(
SpecialCircumstance.GeneratorShortcut,
specialCircumstanceManager.specialCircumstance,
)
}
@Suppress("MaxLineLength")
@Test
fun `changes in the allowed screen capture value should result in emissions of ScreenCaptureSettingChange `() =

View file

@ -34,6 +34,12 @@ class SpecialCircumstanceExtensionsTest {
data = mockk(),
shouldFinishWhenComplete = true,
),
SpecialCircumstance.PasswordlessRequest(
passwordlessRequestData = mockk(),
shouldFinishWhenComplete = true,
),
SpecialCircumstance.GeneratorShortcut,
SpecialCircumstance.VaultShortcut,
)
.forEach { specialCircumstance ->
assertNull(specialCircumstance.toAutofillSaveItemOrNull())
@ -67,6 +73,12 @@ class SpecialCircumstanceExtensionsTest {
data = mockk(),
shouldFinishWhenComplete = true,
),
SpecialCircumstance.PasswordlessRequest(
passwordlessRequestData = mockk(),
shouldFinishWhenComplete = true,
),
SpecialCircumstance.GeneratorShortcut,
SpecialCircumstance.VaultShortcut,
)
.forEach { specialCircumstance ->
assertNull(specialCircumstance.toAutofillSelectionDataOrNull())

View file

@ -2,6 +2,8 @@ package com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.just
@ -16,6 +18,65 @@ class VaultUnlockedNavBarViewModelTest : BaseViewModelTest() {
private val authRepository: AuthRepository = mockk {
every { updateLastActiveTime() } just runs
}
private val specialCircumstancesManager: SpecialCircumstanceManager = mockk {
every { specialCircumstance = null } just runs
every { specialCircumstance } returns null
}
@Suppress("MaxLineLength")
@Test
fun `on init with GeneratorShortcut special circumstance should navigate to the generator screen`() =
runTest {
every {
specialCircumstancesManager.specialCircumstance
} returns SpecialCircumstance.GeneratorShortcut
val viewModel = createViewModel()
viewModel.eventFlow.test {
assertEquals(VaultUnlockedNavBarEvent.NavigateToGeneratorScreen, awaitItem())
}
verify(exactly = 1) {
specialCircumstancesManager.specialCircumstance
specialCircumstancesManager.specialCircumstance = null
}
}
@Suppress("MaxLineLength")
@Test
fun `on init with VaultShortcut special circumstance should navigate to the generator screen`() =
runTest {
every {
specialCircumstancesManager.specialCircumstance
} returns SpecialCircumstance.VaultShortcut
val viewModel = createViewModel()
viewModel.eventFlow.test {
assertEquals(VaultUnlockedNavBarEvent.NavigateToVaultScreen, awaitItem())
}
verify(exactly = 1) {
specialCircumstancesManager.specialCircumstance
specialCircumstancesManager.specialCircumstance = null
}
}
@Test
fun `on init with no shortcut special circumstance should do nothing`() = runTest {
every { specialCircumstancesManager.specialCircumstance } returns null
val viewModel = createViewModel()
viewModel.eventFlow.test {
expectNoEvents()
}
verify(exactly = 1) {
specialCircumstancesManager.specialCircumstance
}
verify(exactly = 0) {
specialCircumstancesManager.specialCircumstance = null
}
}
@Test
fun `VaultTabClick should navigate to the vault screen`() = runTest {
@ -63,5 +124,6 @@ class VaultUnlockedNavBarViewModelTest : BaseViewModelTest() {
private fun createViewModel() =
VaultUnlockedNavBarViewModel(
authRepository = authRepository,
specialCircumstancesManager = specialCircumstancesManager,
)
}

View file

@ -0,0 +1,59 @@
package com.x8bit.bitwarden.ui.platform.util
import android.content.Intent
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class ShortcutUtilsTest {
@Test
fun `isMyVaultShortcut should return true when dataString is my vault deeplink`() {
val mockIntent = mockk<Intent> {
every { dataString } returns "bitwarden://my_vault"
}
assertTrue(mockIntent.isMyVaultShortcut)
}
@Test
fun `isMyVaultShortcut should return false when dataString is not my vault deeplink`() {
val mockIntent = mockk<Intent> {
every { dataString } returns "bitwarden://some_other_vault"
}
assertFalse(mockIntent.isMyVaultShortcut)
}
@Test
fun `isMyVaultShortcut should return false when dataString is null`() {
val mockIntent = mockk<Intent> {
every { dataString } returns null
}
assertFalse(mockIntent.isMyVaultShortcut)
}
@Test
fun `isPasswordGeneratorShortcut should return true when dataString is my vault deeplink`() {
val mockIntent = mockk<Intent> {
every { dataString } returns "bitwarden://password_generator"
}
assertTrue(mockIntent.isPasswordGeneratorShortcut)
}
@Suppress("MaxLineLength")
@Test
fun `isPasswordGeneratorShortcut should return false when dataString is not my vault deeplink`() {
val mockIntent = mockk<Intent> {
every { dataString } returns "bitwarden://some_other_generator"
}
assertFalse(mockIntent.isPasswordGeneratorShortcut)
}
@Test
fun `isPasswordGeneratorShortcut should return false when dataString is null`() {
val mockIntent = mockk<Intent> {
every { dataString } returns null
}
assertFalse(mockIntent.isPasswordGeneratorShortcut)
}
}