diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f8853bb84..4e00ab9a9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,11 @@ android:launchMode="@integer/launchModeAPIlevel" android:theme="@style/LaunchTheme" android:windowSoftInputMode="adjustResize"> + + + diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index b3382fac7..726246697 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -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 + } } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt index 239692e01..598adf39f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt @@ -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() } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt index 9e3ae63ff..23d1c88ce 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt @@ -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 } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index 8375790cc..6d30425d2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -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) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModel.kt index 30af28e34..b95b2f177 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModel.kt @@ -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( - initialState = Unit, - ) { + specialCircumstancesManager: SpecialCircumstanceManager, +) : BaseViewModel( + 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) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/ShortcutUtils.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/ShortcutUtils.kt new file mode 100644 index 000000000..d55f35379 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/ShortcutUtils.kt @@ -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 diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 000000000..ebca17b34 --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/release/res/xml/shortcuts.xml b/app/src/release/res/xml/shortcuts.xml new file mode 100644 index 000000000..ffd41be48 --- /dev/null +++ b/app/src/release/res/xml/shortcuts.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index 7e65c1f2c..3ac891285 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -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 { + 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 { + 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 `() = diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt index 31128dc12..ea22194d2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt @@ -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()) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModelTest.kt index c6657b5d4..df9866b05 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModelTest.kt @@ -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, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ShortcutUtilsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ShortcutUtilsTest.kt new file mode 100644 index 000000000..df8c0c643 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ShortcutUtilsTest.kt @@ -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 { + 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 { + every { dataString } returns "bitwarden://some_other_vault" + } + assertFalse(mockIntent.isMyVaultShortcut) + } + + @Test + fun `isMyVaultShortcut should return false when dataString is null`() { + val mockIntent = mockk { + every { dataString } returns null + } + assertFalse(mockIntent.isMyVaultShortcut) + } + + @Test + fun `isPasswordGeneratorShortcut should return true when dataString is my vault deeplink`() { + val mockIntent = mockk { + 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 { + every { dataString } returns "bitwarden://some_other_generator" + } + assertFalse(mockIntent.isPasswordGeneratorShortcut) + } + + @Test + fun `isPasswordGeneratorShortcut should return false when dataString is null`() { + val mockIntent = mockk { + every { dataString } returns null + } + assertFalse(mockIntent.isPasswordGeneratorShortcut) + } +}