[PM-33513] feat: Add checkout callback deep link handling (#6692)

This commit is contained in:
Patrick Honkonen authored and GitHub committed 2026-03-20 14:16:43 +00:00
1 parent 12c96de168
commit 4a069e9703
6 files changed
+86

No files matched your search

@@ -46,6 +46,7 @@ import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import com.x8bit.bitwarden.ui.platform.util.isPremiumCheckoutCallback
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
@@ -333,6 +334,7 @@ class MainViewModel @Inject constructor(
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
val hasVaultShortcut = intent.isMyVaultShortcut
val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut
val hasPremiumCheckoutCallback = intent.isPremiumCheckoutCallback
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
val importCredentialsRequest = intent.getProviderImportCredentialsRequest()
val credentialProviderRequest =
@@ -394,6 +396,11 @@ class MainViewModel @Inject constructor(
)
}
hasPremiumCheckoutCallback -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.PremiumCheckoutResult
}
hasGeneratorShortcut -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.GeneratorShortcut
@@ -134,6 +134,13 @@ sealed class SpecialCircumstance : Parcelable {
@Parcelize
data object VerificationCodeShortcut : SpecialCircumstance()
/**
* The app was launched via a premium checkout callback deep link,
* indicating the user is returning from a Stripe checkout session.
*/
@Parcelize
data object PremiumCheckoutResult : SpecialCircumstance()
/**
* The app was launched to select an account to export credentials from.
*/
@@ -208,6 +208,7 @@ class RootNavViewModel @Inject constructor(
SpecialCircumstance.AccountSecurityShortcut,
SpecialCircumstance.GeneratorShortcut,
SpecialCircumstance.PremiumCheckoutResult,
SpecialCircumstance.VaultShortcut,
SpecialCircumstance.SendShortcut,
is SpecialCircumstance.SearchShortcut,
@@ -283,6 +284,7 @@ class RootNavViewModel @Inject constructor(
when (specialCircumstance) {
is SpecialCircumstance.AccountSecurityShortcut,
is SpecialCircumstance.GeneratorShortcut,
is SpecialCircumstance.PremiumCheckoutResult,
is SpecialCircumstance.SearchShortcut,
is SpecialCircumstance.SendShortcut,
is SpecialCircumstance.ShareNewSend,
@@ -19,3 +19,10 @@ val Intent.isPasswordGeneratorShortcut: Boolean
*/
val Intent.isAccountSecurityShortcut: Boolean
get() = dataString?.equals("bitwarden://settings/account_security") == true
/**
* Returns `true` if the [Intent] is a deep link callback from a premium
* checkout session, `false` otherwise.
*/
val Intent.isPremiumCheckoutCallback: Boolean
get() = dataString?.equals("bitwarden://premium-upgrade-callback") == true
@@ -71,6 +71,7 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLang
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import com.x8bit.bitwarden.ui.platform.util.isPremiumCheckoutCallback
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import io.mockk.coEvery
import io.mockk.every
@@ -200,6 +201,7 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::isMyVaultShortcut,
Intent::isPasswordGeneratorShortcut,
Intent::isAccountSecurityShortcut,
Intent::isPremiumCheckoutCallback,
)
mockkObject(
ProviderCreateCredentialRequest.Companion,
@@ -231,6 +233,7 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::isMyVaultShortcut,
Intent::isPasswordGeneratorShortcut,
Intent::isAccountSecurityShortcut,
Intent::isPremiumCheckoutCallback,
)
unmockkObject(
ProviderCreateCredentialRequest.Companion,
@@ -859,6 +862,40 @@ class MainViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveFirstIntent with premium checkout callback should set special circumstance to PremiumCheckoutResult`() {
val viewModel = createViewModel()
val mockIntent = createMockIntent(
mockIsPremiumCheckoutCallback = true,
)
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(intent = mockIntent),
)
assertEquals(
SpecialCircumstance.PremiumCheckoutResult,
specialCircumstanceManager.specialCircumstance,
)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveNewIntent with premium checkout callback should set special circumstance to PremiumCheckoutResult`() {
val viewModel = createViewModel()
val mockIntent = createMockIntent(
mockIsPremiumCheckoutCallback = true,
)
viewModel.trySendAction(
MainAction.ReceiveNewIntent(intent = mockIntent),
)
assertEquals(
SpecialCircumstance.PremiumCheckoutResult,
specialCircumstanceManager.specialCircumstance,
)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveNewIntent with a password generator deeplink data should set the special circumstance to GeneratorShortcut`() {
@@ -1264,6 +1301,7 @@ private fun createMockIntent(
mockIsMyVaultShortcut: Boolean = false,
mockIsPasswordGeneratorShortcut: Boolean = false,
mockIsAccountSecurityShortcut: Boolean = false,
mockIsPremiumCheckoutCallback: Boolean = false,
mockIsAddTotpLoginItemFromAuthenticator: Boolean = false,
mockProviderImportCredentialsRequest: ProviderImportCredentialsRequest? = null,
): Intent = mockk<Intent> {
@@ -1275,6 +1313,7 @@ private fun createMockIntent(
every { isMyVaultShortcut } returns mockIsMyVaultShortcut
every { isPasswordGeneratorShortcut } returns mockIsPasswordGeneratorShortcut
every { isAccountSecurityShortcut } returns mockIsAccountSecurityShortcut
every { isPremiumCheckoutCallback } returns mockIsPremiumCheckoutCallback
every { isAddTotpLoginItemFromAuthenticator() } returns mockIsAddTotpLoginItemFromAuthenticator
every { getProviderImportCredentialsRequest() } returns mockProviderImportCredentialsRequest
}
@@ -56,4 +56,28 @@ class ShortcutUtilsTest {
}
assertFalse(mockIntent.isPasswordGeneratorShortcut)
}
@Test
fun `isPremiumCheckoutCallback should return true when dataString is checkout callback`() {
val mockIntent = mockk<Intent> {
every { dataString } returns "bitwarden://premium-upgrade-callback"
}
assertTrue(mockIntent.isPremiumCheckoutCallback)
}
@Test
fun `isPremiumCheckoutCallback should return false when dataString is not checkout callback`() {
val mockIntent = mockk<Intent> {
every { dataString } returns "bitwarden://some_other_callback"
}
assertFalse(mockIntent.isPremiumCheckoutCallback)
}
@Test
fun `isPremiumCheckoutCallback should return false when dataString is null`() {
val mockIntent = mockk<Intent> {
every { dataString } returns null
}
assertFalse(mockIntent.isPremiumCheckoutCallback)
}
}