BIT-1498: Allow external navigation to Add Send screen (#685)

This commit is contained in:
Brian Yencho 2024-01-19 15:01:43 -06:00 committed by Álison Fernandes
parent bdca79d862
commit eeb22dbfee
24 changed files with 358 additions and 58 deletions

View file

@ -26,7 +26,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:launchMode="singleInstancePerTask"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>
@ -44,6 +44,15 @@
android:host="captcha-callback"
android:scheme="bitwarden" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="text/*" />
</intent-filter>
</activity>
<provider

View file

@ -31,6 +31,13 @@ class MainActivity : AppCompatActivity() {
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
super.onCreate(savedInstanceState)
mainViewModel.trySendAction(
MainAction.ReceiveFirstIntent(
intent = intent,
),
)
// Within the app the language will change dynamically and will be managed
// by the OS, but we need to ensure we properly set the language when
// upgrading from older versions that handle this differently.

View file

@ -4,10 +4,12 @@ import android.content.Intent
import android.os.Parcelable
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -21,6 +23,7 @@ import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val intentManager: IntentManager,
settingsRepository: SettingsRepository,
) : BaseViewModel<MainState, Unit, MainAction>(
MainState(
@ -37,6 +40,7 @@ class MainViewModel @Inject constructor(
override fun handleAction(action: MainAction) {
when (action) {
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action)
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
}
}
@ -45,8 +49,22 @@ class MainViewModel @Inject constructor(
mutableStateFlow.update { it.copy(theme = action.theme) }
}
private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) {
val shareData = intentManager.getShareDataFromIntent(action.intent)
when {
shareData != null -> {
authRepository.specialCircumstance =
UserState.SpecialCircumstance.ShareNewSend(
data = shareData,
shouldFinishWhenComplete = true,
)
}
}
}
private fun handleNewIntentReceived(action: MainAction.ReceiveNewIntent) {
val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult()
val shareData = intentManager.getShareDataFromIntent(action.intent)
when {
captchaCallbackTokenResult != null -> {
authRepository.setCaptchaCallbackTokenResult(
@ -54,6 +72,16 @@ class MainViewModel @Inject constructor(
)
}
shareData != null -> {
authRepository.specialCircumstance =
UserState.SpecialCircumstance.ShareNewSend(
data = shareData,
// Allow users back into the already-running app when completing the
// Send task.
shouldFinishWhenComplete = false,
)
}
else -> Unit
}
}
@ -71,6 +99,11 @@ data class MainState(
* Models actions for the [MainActivity].
*/
sealed class MainAction {
/**
* Receive first Intent by the application.
*/
data class ReceiveFirstIntent(val intent: Intent) : MainAction()
/**
* Receive Intent by the application.
*/

View file

@ -48,6 +48,15 @@ interface AuthRepository : AuthenticatorProvider {
*/
var specialCircumstance: UserState.SpecialCircumstance?
/**
* Tracks whether there is an additional account that is pending login/registration in order to
* have multiple accounts available.
*
* This allows a direct view into and modification of [UserState.hasPendingAccountAddition].
* Note that this call has no effect when there is no [UserState] information available.
*/
var hasPendingAccountAddition: Boolean
/**
* Attempt to delete the current account and logout them out upon success.
*/

View file

@ -73,6 +73,7 @@ class AuthRepositoryImpl(
dispatcherManager: DispatcherManager,
private val elapsedRealtimeMillisProvider: () -> Long = { SystemClock.elapsedRealtime() },
) : AuthRepository {
private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow<Boolean>(false)
private val mutableSpecialCircumstanceStateFlow =
MutableStateFlow<UserState.SpecialCircumstance?>(null)
@ -107,12 +108,20 @@ class AuthRepositoryImpl(
authDiskSource.userStateFlow,
authDiskSource.userOrganizationsListFlow,
vaultRepository.vaultStateFlow,
mutableHasPendingAccountAdditionStateFlow,
mutableSpecialCircumstanceStateFlow,
) { userStateJson, userOrganizationsList, vaultState, specialCircumstance ->
) {
userStateJson,
userOrganizationsList,
vaultState,
hasPendingAccountAddition,
specialCircumstance,
->
userStateJson
?.toUserState(
vaultState = vaultState,
userOrganizationsList = userOrganizationsList,
hasPendingAccountAddition = hasPendingAccountAddition,
specialCircumstance = specialCircumstance,
vaultUnlockTypeProvider = ::getVaultUnlockType,
)
@ -125,6 +134,7 @@ class AuthRepositoryImpl(
?.toUserState(
vaultState = vaultRepository.vaultStateFlow.value,
userOrganizationsList = authDiskSource.userOrganizationsList,
hasPendingAccountAddition = mutableHasPendingAccountAdditionStateFlow.value,
specialCircumstance = mutableSpecialCircumstanceStateFlow.value,
vaultUnlockTypeProvider = ::getVaultUnlockType,
),
@ -140,6 +150,9 @@ class AuthRepositoryImpl(
override var specialCircumstance: UserState.SpecialCircumstance?
by mutableSpecialCircumstanceStateFlow::value
override var hasPendingAccountAddition: Boolean
by mutableHasPendingAccountAdditionStateFlow::value
override suspend fun deleteAccount(password: String): DeleteAccountResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return DeleteAccountResult.Error
@ -218,7 +231,7 @@ class AuthRepositoryImpl(
userId = userStateJson.activeUserId,
)
vaultRepository.sync()
specialCircumstance = null
hasPendingAccountAddition = false
LoginResult.Success
}
@ -268,8 +281,8 @@ class AuthRepositoryImpl(
val previousActiveUserId = currentUserState.activeUserId
if (userId == previousActiveUserId) {
// No switching to do but clear any special circumstances
specialCircumstance = null
// No switching to do but clear any pending account additions
hasPendingAccountAddition = false
return SwitchAccountResult.NoChange
}
@ -284,8 +297,8 @@ class AuthRepositoryImpl(
// Clear data for the previous user
vaultRepository.clearUnlockedData()
// Clear any special circumstances
specialCircumstance = null
// Clear any pending account additions
hasPendingAccountAddition = false
return SwitchAccountResult.AccountSwitched
}

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.model
import com.x8bit.bitwarden.data.auth.repository.model.UserState.Account
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
/**
* Represents the overall "user state" of the current active user as well as any users that may be
@ -10,11 +11,14 @@ import com.x8bit.bitwarden.data.platform.repository.model.Environment
* @property activeUserId The ID of the current active user.
* @property accounts A mapping between user IDs and the [Account] information associated with
* that user.
* @property hasPendingAccountAddition Returns `true` if there is an additional account that is
* pending login/registration in order to have multiple accounts available.
* @property specialCircumstance A special circumstance (if any) that may be present.
*/
data class UserState(
val activeUserId: String,
val accounts: List<Account>,
val hasPendingAccountAddition: Boolean = false,
val specialCircumstance: SpecialCircumstance? = null,
) {
init {
@ -27,12 +31,6 @@ data class UserState(
val activeAccount: Account
get() = accounts.first { it.userId == activeUserId }
/**
* Returns `true` if a new user is in the process of being added, `false` otherwise.
*/
val hasPendingAccountAddition: Boolean
get() = specialCircumstance == SpecialCircumstance.PendingAccountAddition
/**
* Basic account information about a given user.
*
@ -65,11 +63,12 @@ data class UserState(
* Represents a special account-related circumstance.
*/
sealed class SpecialCircumstance {
/**
* There is an additional account that is pending login/registration in order to have
* multiple accounts available.
* The app was launched in order to create/share a new Send using the given [data].
*/
data object PendingAccountAddition : SpecialCircumstance()
data class ShareNewSend(
val data: IntentManager.ShareData,
val shouldFinishWhenComplete: Boolean,
) : SpecialCircumstance()
}
}

View file

@ -47,6 +47,7 @@ fun UserStateJson.toUpdatedUserStateJson(
fun UserStateJson.toUserState(
vaultState: VaultState,
userOrganizationsList: List<UserOrganizations>,
hasPendingAccountAddition: Boolean,
specialCircumstance: UserState.SpecialCircumstance?,
vaultUnlockTypeProvider: (userId: String) -> VaultUnlockType,
): UserState =
@ -77,5 +78,6 @@ fun UserStateJson.toUserState(
vaultUnlockType = vaultUnlockTypeProvider(userId),
)
},
hasPendingAccountAddition = hasPendingAccountAddition,
specialCircumstance = specialCircumstance,
)

View file

@ -31,7 +31,7 @@ import java.time.Clock
import javax.inject.Singleton
/**
* Provides repositories in the auth package.
* Provides managers in the platform package.
*/
@Module
@InstallIn(SingletonComponent::class)

View file

@ -98,7 +98,7 @@ class VaultUnlockViewModel @Inject constructor(
}
private fun handleAddAccountClick() {
authRepository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition
authRepository.hasPendingAccountAddition = true
}
private fun handleDismissDialog() {

View file

@ -20,9 +20,12 @@ import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination
import com.x8bit.bitwarden.ui.platform.feature.splash.SPLASH_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.splash.navigateToSplash
import com.x8bit.bitwarden.ui.platform.feature.splash.splashDestination
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.VAULT_UNLOCKED_FOR_NEW_SEND_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.VAULT_UNLOCKED_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlockedForNewSendGraph
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlockedGraph
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedGraph
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedGraphForNewSend
import com.x8bit.bitwarden.ui.platform.theme.RootTransitionProviders
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -31,6 +34,7 @@ import java.util.concurrent.atomic.AtomicReference
/**
* Controls root level [NavHost] for the app.
*/
@Suppress("LongMethod")
@Composable
fun RootNavScreen(
viewModel: RootNavViewModel = hiltViewModel(),
@ -66,6 +70,7 @@ fun RootNavScreen(
authGraph(navController)
vaultUnlockDestination()
vaultUnlockedGraph(navController)
vaultUnlockedGraphForNewSend(navController)
}
val targetRoute = when (state) {
@ -73,6 +78,7 @@ fun RootNavScreen(
RootNavState.Splash -> SPLASH_ROUTE
RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE
is RootNavState.VaultUnlocked -> VAULT_UNLOCKED_GRAPH_ROUTE
RootNavState.VaultUnlockedForNewSend -> VAULT_UNLOCKED_FOR_NEW_SEND_GRAPH_ROUTE
}
val currentRoute = navController.currentDestination?.rootLevelRoute()
@ -102,6 +108,9 @@ fun RootNavScreen(
RootNavState.Splash -> navController.navigateToSplash(rootNavOptions)
RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions)
is RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph(rootNavOptions)
RootNavState.VaultUnlockedForNewSend -> {
navController.navigateToVaultUnlockedForNewSendGraph(rootNavOptions)
}
}
}

View file

@ -51,9 +51,18 @@ class RootNavViewModel @Inject constructor(
userState.hasPendingAccountAddition -> RootNavState.Auth
userState.activeAccount.isVaultUnlocked -> {
RootNavState.VaultUnlocked(
activeUserId = userState.activeAccount.userId,
)
when (userState.specialCircumstance) {
is UserState.SpecialCircumstance.ShareNewSend -> {
RootNavState.VaultUnlockedForNewSend
}
null,
-> {
RootNavState.VaultUnlocked(
activeUserId = userState.activeAccount.userId,
)
}
}
}
else -> RootNavState.VaultLocked
@ -91,6 +100,12 @@ sealed class RootNavState : Parcelable {
data class VaultUnlocked(
val activeUserId: String,
) : RootNavState()
/**
* App should show the new send screen for an unlocked user.
*/
@Parcelize
data object VaultUnlockedForNewSend : RootNavState()
}
/**

View file

@ -14,6 +14,8 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.generatorModalDestination
import com.x8bit.bitwarden.ui.tools.feature.generator.navigateToGeneratorModal
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.navigateToPasswordHistory
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.passwordHistoryDestination
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.ADD_SEND_AS_ROOT_ROUTE
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.addSendAsRootDestination
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.addSendDestination
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.navigateToAddSend
@ -30,6 +32,7 @@ import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.vaultQrCodeScanDestinatio
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
const val VAULT_UNLOCKED_GRAPH_ROUTE: String = "vault_unlocked_graph"
const val VAULT_UNLOCKED_FOR_NEW_SEND_GRAPH_ROUTE: String = "vault_unlocked_for_new_send_graph"
/**
* Navigate to the vault unlocked screen.
@ -38,6 +41,13 @@ fun NavController.navigateToVaultUnlockedGraph(navOptions: NavOptions? = null) {
navigate(VAULT_UNLOCKED_GRAPH_ROUTE, navOptions)
}
/**
* Navigate to the vault unlocked graph for a new send.
*/
fun NavController.navigateToVaultUnlockedForNewSendGraph(navOptions: NavOptions? = null) {
navigate(VAULT_UNLOCKED_FOR_NEW_SEND_GRAPH_ROUTE, navOptions)
}
/**
* Add vault unlocked destinations to the root nav graph.
*/
@ -107,3 +117,17 @@ fun NavGraphBuilder.vaultUnlockedGraph(
generatorModalDestination(onNavigateBack = { navController.popBackStack() })
}
}
/**
* Add vault unlocked destinations for the new send flow to the root nav graph.
*/
fun NavGraphBuilder.vaultUnlockedGraphForNewSend(
navController: NavController,
) {
navigation(
startDestination = ADD_SEND_AS_ROOT_ROUTE,
route = VAULT_UNLOCKED_FOR_NEW_SEND_GRAPH_ROUTE,
) {
addSendAsRootDestination(onNavigateBack = { navController.popBackStack() })
}
}

View file

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.ui.platform.manager.di
import android.content.Context
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManagerImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
/**
* Provides UI-based managers in the platform package.
*/
@Module
@InstallIn(SingletonComponent::class)
class PlatformUiManagerModule {
@Provides
fun provideIntentManager(
@ApplicationContext context: Context,
): IntentManager =
IntentManagerImpl(
context = context,
)
}

View file

@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
/**
* A manager class for simplifying the handling of Android Intents within a given context.
*/
@Suppress("TooManyFunctions")
interface IntentManager {
/**
@ -61,6 +62,11 @@ interface IntentManager {
*/
fun getFileDataFromIntent(activityResult: ActivityResult): FileData?
/**
* Processes the [intent] and attempts to derive [ShareData] information from it.
*/
fun getShareDataFromIntent(intent: Intent): ShareData?
/**
* Creates an intent for choosing a file saved to disk.
*/
@ -74,4 +80,24 @@ interface IntentManager {
val uri: Uri,
val sizeBytes: Long,
)
/**
* Represents data for a share request coming from outside the app.
*/
sealed class ShareData {
/**
* The data required to create a new Text Send.
*/
data class TextSend(
val subject: String?,
val text: String,
) : ShareData()
/**
* The data required to create a new File Send.
*/
data class FileSend(
val fileData: IntentManager.FileData,
) : ShareData()
}
}

View file

@ -122,6 +122,31 @@ class IntentManagerImpl(
return if (uri != null) getLocalFileData(uri) else getCameraFileData()
}
@Suppress("ReturnCount")
override fun getShareDataFromIntent(intent: Intent): IntentManager.ShareData? {
if (intent.action != Intent.ACTION_SEND) return null
return if (intent.type?.contains("text/") == true) {
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val title = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null
IntentManager.ShareData.TextSend(
subject = subject,
text = title,
)
} else {
getFileDataFromIntent(
ActivityResult(
Activity.RESULT_OK,
intent,
),
)
?.let {
IntentManager.ShareData.FileSend(
fileData = it,
)
}
}
}
override fun createFileChooserIntent(withCameraIntents: Boolean): Intent {
val chooserIntent = Intent.createChooser(
Intent(Intent.ACTION_OPEN_DOCUMENT)

View file

@ -20,6 +20,8 @@ private const val ADD_SEND_ITEM_TYPE: String = "add_send_item_type"
private const val ADD_SEND_ROUTE: String =
"$ADD_SEND_ITEM_PREFIX/{$ADD_SEND_ITEM_TYPE}?$EDIT_ITEM_ID={$EDIT_ITEM_ID}"
const val ADD_SEND_AS_ROOT_ROUTE: String = ADD_SEND_ITEM_PREFIX
/**
* Class to retrieve send add & edit arguments from the [SavedStateHandle].
*/
@ -28,9 +30,10 @@ data class AddSendArgs(
val sendAddType: AddSendType,
) {
constructor(savedStateHandle: SavedStateHandle) : this(
sendAddType = when (requireNotNull(savedStateHandle[ADD_SEND_ITEM_TYPE])) {
sendAddType = when (savedStateHandle.get<String>(ADD_SEND_ITEM_TYPE)) {
ADD_TYPE -> AddSendType.AddItem
EDIT_TYPE -> AddSendType.EditItem(requireNotNull(savedStateHandle[EDIT_ITEM_ID]))
null -> AddSendType.AddItem
else -> throw IllegalStateException("Unknown VaultAddEditType.")
},
)
@ -52,6 +55,19 @@ fun NavGraphBuilder.addSendDestination(
}
}
/**
* Add the new send screen to the nav graph as a root destination for a nested graph.
*/
fun NavGraphBuilder.addSendAsRootDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions(
route = ADD_SEND_AS_ROOT_ROUTE,
) {
AddSendScreen(onNavigateBack = onNavigateBack)
}
}
/**
* Navigate to the new send screen.
*/

View file

@ -101,6 +101,9 @@ class AddSendViewModel @Inject constructor(
) {
init {
// TODO: Check the special circumstance to place in custom mode when a new send request is
// initiated externally (BIT-1518).
when (val addSendType = state.addSendType) {
AddSendType.AddItem -> Unit
is AddSendType.EditItem -> {

View file

@ -205,7 +205,7 @@ class VaultViewModel @Inject constructor(
}
private fun handleAddAccountClick() {
authRepository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition
authRepository.hasPendingAccountAddition = true
}
private fun handleSyncClick() {

View file

@ -4,17 +4,23 @@ import android.content.Intent
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
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 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 kotlinx.coroutines.flow.MutableStateFlow
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class MainViewModelTest : BaseViewModelTest() {
@ -24,18 +30,27 @@ class MainViewModelTest : BaseViewModelTest() {
val authRepository = mockk<AuthRepository> {
every { userStateFlow } returns mutableUserStateFlow
every { activeUserId } returns USER_ID
every {
setCaptchaCallbackTokenResult(
tokenResult = CaptchaCallbackTokenResult.Success(
token = "mockk_token",
),
)
} just runs
every { specialCircumstance } returns null
every { specialCircumstance = any() } just runs
every { setCaptchaCallbackTokenResult(any()) } just runs
}
private val settingsRepository = mockk<SettingsRepository> {
every { appTheme } returns AppTheme.DEFAULT
every { appThemeStateFlow } returns mutableAppThemeFlow
}
private val intentManager: IntentManager = mockk {
every { getShareDataFromIntent(any()) } returns null
}
@BeforeEach
fun setUp() {
mockkStatic(CAPTCHA_UTILS_PATH)
}
@AfterEach
fun tearDown() {
unmockkStatic(CAPTCHA_UTILS_PATH)
}
@Test
fun `on AppThemeChanged should update state`() {
@ -65,14 +80,37 @@ class MainViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveFirstIntent with share data should set the special circumstance to ShareNewSend`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent>()
val shareData = mockk<IntentManager.ShareData>()
every { mockIntent.getCaptchaCallbackTokenResult() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
intent = mockIntent,
),
)
verify {
authRepository.specialCircumstance = UserState.SpecialCircumstance.ShareNewSend(
data = shareData,
shouldFinishWhenComplete = true,
)
}
}
@Test
fun `on ReceiveNewIntent with captcha host should call setCaptchaCallbackToken`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent> {
every { data?.host } returns "captcha-callback"
every { data?.getQueryParameter("token") } returns "mockk_token"
every { action } returns Intent.ACTION_VIEW
}
val mockIntent = mockk<Intent>()
every {
mockIntent.getCaptchaCallbackTokenResult()
} returns CaptchaCallbackTokenResult.Success(
token = "mockk_token",
)
viewModel.trySendAction(
MainAction.ReceiveNewIntent(
intent = mockIntent,
@ -87,12 +125,37 @@ class MainViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveNewIntent with share data should set the special circumstance to ShareNewSend`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent>()
val shareData = mockk<IntentManager.ShareData>()
every { mockIntent.getCaptchaCallbackTokenResult() } returns null
every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData
viewModel.trySendAction(
MainAction.ReceiveNewIntent(
intent = mockIntent,
),
)
verify {
authRepository.specialCircumstance = UserState.SpecialCircumstance.ShareNewSend(
data = shareData,
shouldFinishWhenComplete = false,
)
}
}
private fun createViewModel() = MainViewModel(
authRepository = authRepository,
settingsRepository = settingsRepository,
intentManager = intentManager,
)
companion object {
private const val CAPTCHA_UTILS_PATH =
"com.x8bit.bitwarden.data.auth.repository.util.CaptchaUtilsKt"
private const val USER_ID = "userID"
private val DEFAULT_USER_STATE = UserState(
activeUserId = USER_ID,

View file

@ -68,6 +68,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
@ -217,6 +218,7 @@ class AuthRepositoryTest {
SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_STATE,
userOrganizationsList = emptyList(),
hasPendingAccountAddition = false,
specialCircumstance = null,
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
),
@ -238,6 +240,7 @@ class AuthRepositoryTest {
MULTI_USER_STATE.toUserState(
vaultState = VAULT_STATE,
userOrganizationsList = emptyList(),
hasPendingAccountAddition = false,
specialCircumstance = null,
vaultUnlockTypeProvider = { VaultUnlockType.PIN },
),
@ -253,6 +256,7 @@ class AuthRepositoryTest {
MULTI_USER_STATE.toUserState(
vaultState = emptyVaultState,
userOrganizationsList = emptyList(),
hasPendingAccountAddition = false,
specialCircumstance = null,
vaultUnlockTypeProvider = { VaultUnlockType.PIN },
),
@ -277,6 +281,7 @@ class AuthRepositoryTest {
MULTI_USER_STATE.toUserState(
vaultState = emptyVaultState,
userOrganizationsList = USER_ORGANIZATIONS,
hasPendingAccountAddition = false,
specialCircumstance = null,
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
),
@ -306,6 +311,7 @@ class AuthRepositoryTest {
val initialUserState = SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_STATE,
userOrganizationsList = emptyList(),
hasPendingAccountAddition = false,
specialCircumstance = null,
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
)
@ -316,11 +322,12 @@ class AuthRepositoryTest {
repository.userStateFlow.value,
)
repository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition
val mockSpecialCircumstance: UserState.SpecialCircumstance = mockk()
repository.specialCircumstance = mockSpecialCircumstance
assertEquals(
initialUserState.copy(
specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition,
specialCircumstance = mockSpecialCircumstance,
),
repository.userStateFlow.value,
)
@ -595,7 +602,7 @@ class AuthRepositoryTest {
runTest {
// Ensure the initial state for User 2 with a account addition
fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
repository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition
repository.hasPendingAccountAddition = true
// Set up login for User 1
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
@ -665,7 +672,7 @@ class AuthRepositoryTest {
MULTI_USER_STATE,
fakeAuthDiskSource.userState,
)
assertNull(repository.specialCircumstance)
assertFalse(repository.hasPendingAccountAddition)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify { vaultRepository.clearUnlockedData() }
}
@ -1076,11 +1083,12 @@ class AuthRepositoryTest {
@Suppress("MaxLineLength")
@Test
fun `switchAccount when the given userId is the same as the current activeUserId should only clear any special circumstances`() {
fun `switchAccount when the given userId is the same as the current activeUserId should reset any pending account additions`() {
val originalUserId = USER_ID_1
val originalUserState = SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_STATE,
userOrganizationsList = emptyList(),
hasPendingAccountAddition = false,
specialCircumstance = null,
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
)
@ -1089,7 +1097,7 @@ class AuthRepositoryTest {
originalUserState,
repository.userStateFlow.value,
)
repository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition
repository.hasPendingAccountAddition = true
assertEquals(
SwitchAccountResult.NoChange,
@ -1100,7 +1108,7 @@ class AuthRepositoryTest {
originalUserState,
repository.userStateFlow.value,
)
assertNull(repository.specialCircumstance)
assertFalse(repository.hasPendingAccountAddition)
verify(exactly = 0) { vaultRepository.clearUnlockedData() }
}
@ -1111,6 +1119,7 @@ class AuthRepositoryTest {
val originalUserState = SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_STATE,
userOrganizationsList = emptyList(),
hasPendingAccountAddition = false,
specialCircumstance = null,
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
)
@ -1134,11 +1143,12 @@ class AuthRepositoryTest {
@Suppress("MaxLineLength")
@Test
fun `switchAccount when the userId is valid should update the current UserState, clear the previously unlocked data, and reset the special circumstance`() {
fun `switchAccount when the userId is valid should update the current UserState, clear the previously unlocked data, and reset any pending account additions`() {
val updatedUserId = USER_ID_2
val originalUserState = MULTI_USER_STATE.toUserState(
vaultState = VAULT_STATE,
userOrganizationsList = emptyList(),
hasPendingAccountAddition = false,
specialCircumstance = null,
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
)
@ -1147,7 +1157,7 @@ class AuthRepositoryTest {
originalUserState,
repository.userStateFlow.value,
)
repository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition
repository.hasPendingAccountAddition = true
assertEquals(
SwitchAccountResult.AccountSwitched,
@ -1158,7 +1168,7 @@ class AuthRepositoryTest {
originalUserState.copy(activeUserId = updatedUserId),
repository.userStateFlow.value,
)
assertNull(repository.specialCircumstance)
assertFalse(repository.hasPendingAccountAddition)
verify { vaultRepository.clearUnlockedData() }
}

View file

@ -154,6 +154,7 @@ class UserStateJsonExtensionsTest {
),
),
),
hasPendingAccountAddition = false,
specialCircumstance = null,
vaultUnlockTypeProvider = { VaultUnlockType.PIN },
),
@ -185,7 +186,8 @@ class UserStateJsonExtensionsTest {
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
),
),
specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition,
hasPendingAccountAddition = true,
specialCircumstance = MOCK_SPECIAL_CIRCUMSTANCE,
),
UserStateJson(
activeUserId = "activeUserId",
@ -224,9 +226,12 @@ class UserStateJsonExtensionsTest {
),
),
),
specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition,
hasPendingAccountAddition = true,
specialCircumstance = MOCK_SPECIAL_CIRCUMSTANCE,
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
),
)
}
}
private val MOCK_SPECIAL_CIRCUMSTANCE: UserState.SpecialCircumstance = mockk()

View file

@ -6,7 +6,6 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJso
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.repository.model.UserState.SpecialCircumstance
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
@ -38,8 +37,8 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
private val authRepository = mockk<AuthRepository>() {
every { activeUserId } answers { mutableUserStateFlow.value?.activeUserId }
every { userStateFlow } returns mutableUserStateFlow
every { specialCircumstance } returns null
every { specialCircumstance = any() } just runs
every { hasPendingAccountAddition } returns false
every { hasPendingAccountAddition = any() } just runs
every { logout() } just runs
every { logout(any()) } just runs
every { switchAccount(any()) } returns SwitchAccountResult.AccountSwitched
@ -174,11 +173,11 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `on AddAccountClick should update the SpecialCircumstance of the AuthRepository to PendingAccountAddition`() {
fun `on AddAccountClick should set hasPendingAccountAddition to true on the AuthRepository`() {
val viewModel = createViewModel()
viewModel.trySendAction(VaultUnlockAction.AddAccountClick)
verify {
authRepository.specialCircumstance = SpecialCircumstance.PendingAccountAddition
authRepository.hasPendingAccountAddition = true
}
}

View file

@ -89,5 +89,14 @@ class RootNavScreenTest : BaseComposeTest() {
navOptions = expectedNavOptions,
)
}
// Make sure navigating to vault unlocked works as expected:
rootNavStateFlow.value = RootNavState.VaultUnlockedForNewSend
composeTestRule.runOnIdle {
fakeNavHostController.assertLastNavigation(
route = "vault_unlocked_for_new_send_graph",
navOptions = expectedNavOptions,
)
}
}
}

View file

@ -6,7 +6,6 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.UserState.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.model.Environment
@ -53,8 +52,8 @@ class VaultViewModelTest : BaseViewModelTest() {
private val authRepository: AuthRepository =
mockk {
every { userStateFlow } returns mutableUserStateFlow
every { specialCircumstance } returns null
every { specialCircumstance = any() } just runs
every { hasPendingAccountAddition } returns false
every { hasPendingAccountAddition = any() } just runs
every { logout(any()) } just runs
every { switchAccount(any()) } answers { switchAccountResult }
}
@ -289,11 +288,11 @@ class VaultViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `on AddAccountClick should update the SpecialCircumstance of the AuthRepository to PendingAccountAddition`() {
fun `on AddAccountClick should set hasPendingAccountAddition to true on the AuthRepository`() {
val viewModel = createViewModel()
viewModel.trySendAction(VaultAction.AddAccountClick)
verify {
authRepository.specialCircumstance = SpecialCircumstance.PendingAccountAddition
authRepository.hasPendingAccountAddition = true
}
}