diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 93d6aabdc4..048625232c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -55,6 +55,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
index 1c31b93d37..26e4254cc5 100644
--- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
@@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
+import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2AssertionRequestOrNull
@@ -34,6 +35,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
+import java.time.Clock
import javax.inject.Inject
private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
@@ -53,6 +55,7 @@ class MainViewModel @Inject constructor(
private val vaultRepository: VaultRepository,
private val authRepository: AuthRepository,
private val savedStateHandle: SavedStateHandle,
+ private val clock: Clock,
) : BaseViewModel(
initialState = MainState(
theme = settingsRepository.appTheme,
@@ -188,6 +191,7 @@ class MainViewModel @Inject constructor(
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
val hasVaultShortcut = intent.isMyVaultShortcut
val fido2CredentialRequestData = intent.getFido2CredentialRequestOrNull()
+ val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
val fido2CredentialAssertionRequest = intent.getFido2AssertionRequestOrNull()
val fido2GetCredentialsRequest = intent.getFido2GetCredentialsRequestOrNull()
when {
@@ -201,6 +205,17 @@ class MainViewModel @Inject constructor(
)
}
+ completeRegistrationData != null -> {
+ if (authRepository.activeUserId != null) {
+ authRepository.hasPendingAccountAddition = true
+ }
+ specialCircumstanceManager.specialCircumstance =
+ SpecialCircumstance.CompleteRegistration(
+ completeRegistrationData = completeRegistrationData,
+ timestamp = clock.millis(),
+ )
+ }
+
autofillSaveItem != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.AutofillSave(
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtils.kt
new file mode 100644
index 0000000000..e9ddce9f05
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtils.kt
@@ -0,0 +1,24 @@
+package com.x8bit.bitwarden.data.auth.util
+
+import android.content.Intent
+import android.net.Uri
+import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
+
+/**
+ * Checks if the given [Intent] contains data to complete registration.
+ * The [CompleteRegistrationData] will be returned when present.
+ */
+fun Intent.getCompleteRegistrationDataIntentOrNull(): CompleteRegistrationData? {
+ val sanitizedUriString = data.toString().replace("/#/", "/")
+ val uri = runCatching { Uri.parse(sanitizedUriString) }.getOrNull() ?: return null
+ uri.host ?: return null
+ if (uri.path != "/finish-signup") return null
+ val email = uri.getQueryParameter("email") ?: return null
+ val verificationToken = uri.getQueryParameter("token") ?: return null
+ val fromEmail = uri.getBooleanQueryParameter("fromEmail", true)
+ return CompleteRegistrationData(
+ email = email,
+ verificationToken = verificationToken,
+ fromEmail = fromEmail,
+ )
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/CompleteRegistrationData.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/CompleteRegistrationData.kt
new file mode 100644
index 0000000000..72134216b7
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/CompleteRegistrationData.kt
@@ -0,0 +1,18 @@
+package com.x8bit.bitwarden.data.platform.manager.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Required data to complete ongoing registration process.
+ *
+ * @property email The email of the user creating the account.
+ * @property verificationToken The token required to finish the registration process.
+ * @property fromEmail indicates that this information came from an email AppLink.
+ */
+@Parcelize
+data class CompleteRegistrationData(
+ val email: String,
+ val verificationToken: String,
+ val fromEmail: Boolean,
+) : Parcelable
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 4e2d386b74..915c58b199 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
@@ -50,6 +50,15 @@ sealed class SpecialCircumstance : Parcelable {
val shouldFinishWhenComplete: Boolean,
) : SpecialCircumstance()
+ /**
+ * The app was launched via AppLink in order to allow the user complete an ongoing registration.
+ */
+ @Parcelize
+ data class CompleteRegistration(
+ val completeRegistrationData: CompleteRegistrationData,
+ val timestamp: Long,
+ ) : SpecialCircumstance()
+
/**
* The app was launched via the credential manager framework in order to allow the user to
* manually save a passkey to their vault.
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 97fa9abcc1..ccaaacd933 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
@@ -19,6 +19,7 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
SpecialCircumstance.GeneratorShortcut -> null
SpecialCircumstance.VaultShortcut -> null
is SpecialCircumstance.Fido2Save -> null
+ is SpecialCircumstance.CompleteRegistration -> null
is SpecialCircumstance.Fido2Assertion -> null
is SpecialCircumstance.Fido2GetCredentials -> null
}
@@ -35,6 +36,7 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
SpecialCircumstance.GeneratorShortcut -> null
SpecialCircumstance.VaultShortcut -> null
is SpecialCircumstance.Fido2Save -> null
+ is SpecialCircumstance.CompleteRegistration -> null
is SpecialCircumstance.Fido2Assertion -> null
is SpecialCircumstance.Fido2GetCredentials -> null
}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt
index 312d3dfec0..a1b76b7f5e 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt
@@ -85,8 +85,10 @@ fun RootNavScreen(
}
val targetRoute = when (state) {
- RootNavState.Auth -> AUTH_GRAPH_ROUTE
- RootNavState.AuthWithWelcome -> AUTH_GRAPH_ROUTE
+ RootNavState.Auth,
+ is RootNavState.CompleteOngoingRegistration,
+ RootNavState.AuthWithWelcome,
+ -> AUTH_GRAPH_ROUTE
RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE
RootNavState.SetPassword -> SET_PASSWORD_ROUTE
RootNavState.Splash -> SPLASH_ROUTE
@@ -189,6 +191,11 @@ fun RootNavScreen(
navOptions = rootNavOptions,
)
}
+
+ is RootNavState.CompleteOngoingRegistration -> {
+ navController.navigateToAuthGraph(rootNavOptions)
+ // TODO PR-3622: add navigation to complete registration
+ }
}
}
}
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 ca1a6821d0..4507b6c4f0 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
@@ -57,6 +57,7 @@ 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 &&
@@ -66,6 +67,15 @@ class RootNavViewModel @Inject constructor(
userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword
+ specialCircumstance is SpecialCircumstance.CompleteRegistration -> {
+ RootNavState.CompleteOngoingRegistration(
+ email = specialCircumstance.completeRegistrationData.email,
+ verificationToken = specialCircumstance.completeRegistrationData.verificationToken,
+ fromEmail = specialCircumstance.completeRegistrationData.fromEmail,
+ timestamp = specialCircumstance.timestamp,
+ )
+ }
+
userState == null ||
!userState.activeAccount.isLoggedIn ||
userState.hasPendingAccountAddition -> {
@@ -77,7 +87,7 @@ class RootNavViewModel @Inject constructor(
}
userState.activeAccount.isVaultUnlocked -> {
- when (val specialCircumstance = action.specialCircumstance) {
+ when (specialCircumstance) {
is SpecialCircumstance.AutofillSave -> {
RootNavState.VaultUnlockedForAutofillSave(
autofillSaveItem = specialCircumstance.autofillSaveItem,
@@ -122,6 +132,12 @@ class RootNavViewModel @Inject constructor(
SpecialCircumstance.VaultShortcut,
null,
-> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId)
+
+ is SpecialCircumstance.CompleteRegistration -> {
+ throw IllegalStateException(
+ "Special circumstance should have been already handled.",
+ )
+ }
}
}
@@ -242,6 +258,17 @@ sealed class RootNavState : Parcelable {
@Parcelize
data object VaultUnlockedForNewSend : RootNavState()
+ /**
+ * App should show the screen to complete an ongoing registration process.
+ */
+ @Parcelize
+ data class CompleteOngoingRegistration(
+ val email: String,
+ val verificationToken: String,
+ val fromEmail: Boolean,
+ val timestamp: Long,
+ ) : RootNavState()
+
/**
* App should show the auth confirmation screen for an unlocked user.
*/
diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
index eae318fae8..58e037cd77 100644
--- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
@@ -8,6 +8,7 @@ import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
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.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
@@ -28,6 +29,7 @@ import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
+import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -55,7 +57,11 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
+import java.time.Clock
+import java.time.Instant
+import java.time.ZoneOffset
+@Suppress("LargeClass")
class MainViewModelTest : BaseViewModelTest() {
private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl()
@@ -96,6 +102,7 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::getPasswordlessRequestDataIntentOrNull,
Intent::getAutofillSaveItemOrNull,
Intent::getAutofillSelectionDataOrNull,
+ Intent::getCompleteRegistrationDataIntentOrNull,
Intent::getFido2CredentialRequestOrNull,
)
mockkStatic(
@@ -110,6 +117,7 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::getPasswordlessRequestDataIntentOrNull,
Intent::getAutofillSaveItemOrNull,
Intent::getAutofillSelectionDataOrNull,
+ Intent::getCompleteRegistrationDataIntentOrNull,
)
unmockkStatic(
Intent::isMyVaultShortcut,
@@ -261,6 +269,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
+ every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
@@ -287,6 +296,7 @@ class MainViewModelTest : BaseViewModelTest() {
val autofillSelectionData = mockk()
every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null
every { mockIntent.getAutofillSaveItemOrNull() } returns null
+ every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns autofillSelectionData
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
@@ -306,6 +316,36 @@ class MainViewModelTest : BaseViewModelTest() {
)
}
+ @Suppress("MaxLineLength")
+ @Test
+ fun `on ReceiveFirstIntent with complete registration data should set the special circumstance to CompleteRegistration`() {
+ val viewModel = createViewModel()
+ val completeRegistrationData = mockk()
+ val mockIntent = mockk {
+ every { getPasswordlessRequestDataIntentOrNull() } returns null
+ every { getAutofillSaveItemOrNull() } returns null
+ every { getCompleteRegistrationDataIntentOrNull() } returns completeRegistrationData
+ every { getAutofillSelectionDataOrNull() } returns null
+ every { isMyVaultShortcut } returns false
+ every { isPasswordGeneratorShortcut } returns false
+ }
+ every { intentManager.getShareDataFromIntent(mockIntent) } returns null
+ every { authRepository.activeUserId } returns null
+
+ viewModel.trySendAction(
+ MainAction.ReceiveFirstIntent(
+ intent = mockIntent,
+ ),
+ )
+ assertEquals(
+ SpecialCircumstance.CompleteRegistration(
+ completeRegistrationData = completeRegistrationData,
+ timestamp = FIXED_CLOCK.millis(),
+ ),
+ specialCircumstanceManager.specialCircumstance,
+ )
+ }
+
@Suppress("MaxLineLength")
@Test
fun `on ReceiveFirstIntent with an autofill save item should set the special circumstance to AutofillSave`() {
@@ -315,6 +355,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null
every { mockIntent.getAutofillSaveItemOrNull() } returns autofillSaveItem
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
+ every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
@@ -343,6 +384,7 @@ class MainViewModelTest : BaseViewModelTest() {
} returns passwordlessRequestData
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
+ every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
@@ -426,6 +468,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
+ every { getCompleteRegistrationDataIntentOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns false
}
@@ -459,6 +502,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
+ every { getCompleteRegistrationDataIntentOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns false
}
@@ -527,6 +571,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
+ every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
@@ -553,6 +598,7 @@ class MainViewModelTest : BaseViewModelTest() {
val autofillSelectionData = mockk()
every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null
every { mockIntent.getAutofillSaveItemOrNull() } returns null
+ every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns autofillSelectionData
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
@@ -581,6 +627,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null
every { mockIntent.getAutofillSaveItemOrNull() } returns autofillSaveItem
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
+ every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
@@ -609,6 +656,7 @@ class MainViewModelTest : BaseViewModelTest() {
} returns passwordlessRequestData
every { mockIntent.getAutofillSaveItemOrNull() } returns null
every { mockIntent.getAutofillSelectionDataOrNull() } returns null
+ every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
every { mockIntent.isMyVaultShortcut } returns false
every { mockIntent.isPasswordGeneratorShortcut } returns false
@@ -635,6 +683,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
+ every { getCompleteRegistrationDataIntentOrNull() } returns null
every { isMyVaultShortcut } returns true
every { isPasswordGeneratorShortcut } returns false
}
@@ -659,6 +708,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
+ every { getCompleteRegistrationDataIntentOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns true
}
@@ -700,6 +750,7 @@ class MainViewModelTest : BaseViewModelTest() {
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
authRepository = authRepository,
+ clock = FIXED_CLOCK,
savedStateHandle = savedStateHandle.apply {
set(SPECIAL_CIRCUMSTANCE_KEY, initialSpecialCircumstance)
},
@@ -740,6 +791,7 @@ private fun createMockFido2RegistrationIntent(
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
+ every { getCompleteRegistrationDataIntentOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns false
}
@@ -752,6 +804,7 @@ private fun createMockFido2AssertionIntent(
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
+ every { getCompleteRegistrationDataIntentOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns false
}
@@ -767,6 +820,12 @@ private fun createMockFido2GetCredentialsIntent(
every { getAutofillSaveItemOrNull() } returns null
every { getFido2CredentialRequestOrNull() } returns null
every { getFido2AssertionRequestOrNull() } returns null
+ every { getCompleteRegistrationDataIntentOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns false
}
+
+private val FIXED_CLOCK: Clock = Clock.fixed(
+ Instant.parse("2023-10-27T12:00:00Z"),
+ ZoneOffset.UTC,
+)
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtilsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtilsTest.kt
new file mode 100644
index 0000000000..068f17e7b3
--- /dev/null
+++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtilsTest.kt
@@ -0,0 +1,121 @@
+package com.x8bit.bitwarden.data.auth.util
+
+import android.content.Intent
+import android.net.Uri
+import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+class CompleteRegistrationDataUtilsTest {
+
+ @BeforeEach
+ fun setup() {
+ mockkStatic(Uri::class)
+ }
+
+ @AfterEach
+ fun tearDown() {
+ unmockkStatic(Uri::class)
+ }
+
+ @Test
+ fun `getCompleteRegistrationDataIntentOrNull valid URI returns CompleteRegistrationData`() {
+ val mockIntent = mockk {
+ every { data } returns mockk()
+ }
+ val uriMock = mockk()
+ every { Uri.parse(any()) } returns uriMock
+ every { uriMock.host } returns "www.bitwarden.com"
+ every { uriMock.path } returns "/finish-signup"
+ every { uriMock.getQueryParameter("email") } returns "example@email.com"
+ every { uriMock.getQueryParameter("token") } returns "verificationtoken"
+ every { uriMock.getBooleanQueryParameter("fromEmail", true) } returns true
+
+ assertEquals(
+ CompleteRegistrationData(
+ email = "example@email.com",
+ verificationToken = "verificationtoken",
+ fromEmail = true,
+ ),
+ mockIntent.getCompleteRegistrationDataIntentOrNull(),
+ )
+ }
+
+ @Test
+ fun `getCompleteRegistrationDataIntentOrNull null data returns null`() {
+ val mockIntent = mockk {
+ every { data } returns mockk()
+ }
+ every { Uri.parse(any()) } returns null
+
+ assertNull(
+ mockIntent.getCompleteRegistrationDataIntentOrNull(),
+ )
+ }
+
+ @Test
+ fun `getCompleteRegistrationDataIntentOrNull Uri with no host`() {
+ val mockIntent = mockk {
+ every { data } returns mockk()
+ }
+ val uriMock = mockk()
+ every { Uri.parse(any()) } returns null
+ every { uriMock.host } returns null
+
+ assertNull(
+ mockIntent.getCompleteRegistrationDataIntentOrNull(),
+ )
+ }
+
+ @Test
+ fun `getCompleteRegistrationDataIntentOrNull URI does not contain finish-signup path`() {
+ val mockIntent = mockk {
+ every { data } returns mockk()
+ }
+ val uriMock = mockk()
+ every { Uri.parse(any()) } returns null
+ every { uriMock.host } returns null
+ every { uriMock.path } returns "/finish"
+ assertNull(
+ mockIntent.getCompleteRegistrationDataIntentOrNull(),
+ )
+ }
+
+ @Test
+ fun `getCompleteRegistrationDataIntentOrNull URI does not contain parameter email`() {
+ val mockIntent = mockk {
+ every { data } returns mockk()
+ }
+ val uriMock = mockk()
+ every { Uri.parse(any()) } returns uriMock
+ every { uriMock.host } returns "www.bitwarden.com"
+ every { uriMock.path } returns "/finish-signup"
+ every { uriMock.getQueryParameter("email") } returns null
+ assertNull(
+ mockIntent.getCompleteRegistrationDataIntentOrNull(),
+ )
+ }
+
+ @Test
+ fun `getCompleteRegistrationDataIntentOrNull URI does not contain parameter token`() {
+ val mockIntent = mockk {
+ every { data } returns mockk()
+ }
+ val uriMock = mockk()
+ every { Uri.parse(any()) } returns uriMock
+ every { uriMock.host } returns "www.bitwarden.com"
+ every { uriMock.path } returns "/finish-signup"
+ every { uriMock.getQueryParameter("email") } returns "example@email.com"
+ every { uriMock.getQueryParameter("token") } returns null
+ assertNull(
+ mockIntent.getCompleteRegistrationDataIntentOrNull(),
+ )
+ }
+}
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt
index b9fac81fa9..a99a48f114 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt
@@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2GetCredentia
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
+import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
@@ -17,7 +18,11 @@ import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
+import java.time.Clock
+import java.time.Instant
+import java.time.ZoneOffset
+@Suppress("LargeClass")
class RootNavViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow(null)
private val authRepository = mockk {
@@ -550,6 +555,129 @@ class RootNavViewModelTest : BaseViewModelTest() {
)
}
+ @Suppress("MaxLineLength")
+ @Test
+ fun `when there are no accounts but there is a CompleteRegistration special circumstance the nav state should be CompleteRegistration`() {
+ every { authRepository.hasPendingAccountAddition } returns false
+
+ specialCircumstanceManager.specialCircumstance =
+ SpecialCircumstance.CompleteRegistration(
+ CompleteRegistrationData(
+ email = "example@email.com",
+ verificationToken = "token",
+ fromEmail = true,
+ ),
+ FIXED_CLOCK.instant().toEpochMilli(),
+ )
+ mutableUserStateFlow.tryEmit(null)
+ val viewModel = createViewModel()
+ assertEquals(
+ RootNavState.CompleteOngoingRegistration(
+ email = "example@email.com",
+ verificationToken = "token",
+ fromEmail = true,
+ timestamp = FIXED_CLOCK.instant().toEpochMilli(),
+ ),
+ viewModel.stateFlow.value,
+ )
+ }
+
+ @Suppress("MaxLineLength")
+ @Test
+ fun `when the active user has an unlocked vault but there is a CompleteRegistration special circumstance the nav state should be CompleteRegistration`() {
+ every { authRepository.hasPendingAccountAddition } returns true
+
+ specialCircumstanceManager.specialCircumstance =
+ SpecialCircumstance.CompleteRegistration(
+ CompleteRegistrationData(
+ email = "example@email.com",
+ verificationToken = "token",
+ fromEmail = true,
+ ),
+ FIXED_CLOCK.instant().toEpochMilli(),
+ )
+ mutableUserStateFlow.tryEmit(
+ UserState(
+ activeUserId = "activeUserId",
+ accounts = listOf(
+ UserState.Account(
+ userId = "activeUserId",
+ name = "name",
+ email = "email",
+ avatarColorHex = "avatarHexColor",
+ environment = Environment.Us,
+ isPremium = true,
+ isLoggedIn = true,
+ isVaultUnlocked = true,
+ needsPasswordReset = false,
+ isBiometricsEnabled = false,
+ organizations = emptyList(),
+ needsMasterPassword = false,
+ trustedDevice = null,
+ ),
+ ),
+ ),
+ )
+ val viewModel = createViewModel()
+ assertEquals(
+ RootNavState.CompleteOngoingRegistration(
+ email = "example@email.com",
+ verificationToken = "token",
+ fromEmail = true,
+ timestamp = FIXED_CLOCK.instant().toEpochMilli(),
+ ),
+ viewModel.stateFlow.value,
+ )
+ }
+
+ @Suppress("MaxLineLength")
+ @Test
+ fun `when the active user has a locked vault but there is a CompleteRegistration special circumstance the nav state should be CompleteRegistration`() {
+ every { authRepository.hasPendingAccountAddition } returns true
+
+ specialCircumstanceManager.specialCircumstance =
+ SpecialCircumstance.CompleteRegistration(
+ CompleteRegistrationData(
+ email = "example@email.com",
+ verificationToken = "token",
+ fromEmail = true,
+ ),
+ FIXED_CLOCK.instant().toEpochMilli(),
+ )
+ mutableUserStateFlow.tryEmit(
+ UserState(
+ activeUserId = "activeUserId",
+ accounts = listOf(
+ UserState.Account(
+ userId = "activeUserId",
+ name = "name",
+ email = "email",
+ avatarColorHex = "avatarColorHex",
+ environment = Environment.Us,
+ isPremium = true,
+ isLoggedIn = true,
+ isVaultUnlocked = false,
+ needsPasswordReset = false,
+ isBiometricsEnabled = false,
+ organizations = emptyList(),
+ needsMasterPassword = false,
+ trustedDevice = null,
+ ),
+ ),
+ ),
+ )
+ val viewModel = createViewModel()
+ assertEquals(
+ RootNavState.CompleteOngoingRegistration(
+ email = "example@email.com",
+ verificationToken = "token",
+ fromEmail = true,
+ timestamp = FIXED_CLOCK.instant().toEpochMilli(),
+ ),
+ viewModel.stateFlow.value,
+ )
+ }
+
@Test
fun `when the active user has a locked vault the nav state should be VaultLocked`() {
mutableUserStateFlow.tryEmit(
@@ -583,4 +711,9 @@ class RootNavViewModelTest : BaseViewModelTest() {
authRepository = authRepository,
specialCircumstanceManager = specialCircumstanceManager,
)
+
+ private val FIXED_CLOCK: Clock = Clock.fixed(
+ Instant.parse("2023-10-27T12:00:00Z"),
+ ZoneOffset.UTC,
+ )
}