mirror of
https://github.com/bitwarden/android.git
synced 2025-02-13 18:39:56 +03:00
PM-13886 show dialog when no logins were imported (#4139)
This commit is contained in:
parent
a55fbca16a
commit
b0885ff60a
8 changed files with 210 additions and 48 deletions
app/src
main
java/com/x8bit/bitwarden
data/vault/repository
ui/vault/feature/importlogins
res/values
test/java/com/x8bit/bitwarden
data/vault/repository
ui/vault/feature/importlogins
|
@ -1336,7 +1336,12 @@ class VaultRepositoryImpl(
|
|||
userId = userId,
|
||||
lastSyncTime = clock.instant(),
|
||||
)
|
||||
return SyncVaultDataResult.Success
|
||||
val itemsAvailable = vaultDiskSource
|
||||
.getCiphers(userId)
|
||||
.firstOrNull()
|
||||
?.isNotEmpty()
|
||||
?: false
|
||||
return SyncVaultDataResult.Success(itemsAvailable = itemsAvailable)
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
|
@ -1381,7 +1386,8 @@ class VaultRepositoryImpl(
|
|||
lastSyncTime = clock.instant(),
|
||||
)
|
||||
vaultDiskSource.replaceVaultData(userId = userId, vault = syncResponse)
|
||||
return SyncVaultDataResult.Success
|
||||
val itemsAvailable = syncResponse.ciphers?.isNotEmpty() ?: false
|
||||
return SyncVaultDataResult.Success(itemsAvailable = itemsAvailable)
|
||||
},
|
||||
onFailure = { throwable ->
|
||||
updateVaultStateFlowsToError(throwable)
|
||||
|
|
|
@ -6,8 +6,10 @@ package com.x8bit.bitwarden.data.vault.repository.model
|
|||
sealed class SyncVaultDataResult {
|
||||
/**
|
||||
* Indicates a successful sync operation.
|
||||
*
|
||||
* @property itemsAvailable indicated whether the sync returned any vault items or not.
|
||||
*/
|
||||
data object Success : SyncVaultDataResult()
|
||||
data class Success(val itemsAvailable: Boolean) : SyncVaultDataResult()
|
||||
|
||||
/**
|
||||
* Indicates a failed sync operation.
|
||||
|
|
|
@ -222,7 +222,7 @@ private fun ImportLoginsDialogContent(
|
|||
)
|
||||
}
|
||||
|
||||
ImportLoginsState.DialogState.Error -> {
|
||||
is ImportLoginsState.DialogState.Error -> {
|
||||
BitwardenTwoButtonDialog(
|
||||
title = dialogState.title?.invoke(),
|
||||
message = dialogState.message(),
|
||||
|
|
|
@ -82,22 +82,33 @@ class ImportLoginsViewModel @Inject constructor(
|
|||
private fun handleVaultSyncResultReceived(
|
||||
action: ImportLoginsAction.Internal.VaultSyncResultReceived,
|
||||
) {
|
||||
when (action.result) {
|
||||
when (val result = action.result) {
|
||||
is SyncVaultDataResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isVaultSyncing = false,
|
||||
dialogState = ImportLoginsState.DialogState.Error,
|
||||
dialogState = ImportLoginsState.DialogState.Error(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SyncVaultDataResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
showBottomSheet = true,
|
||||
isVaultSyncing = false,
|
||||
)
|
||||
is SyncVaultDataResult.Success -> {
|
||||
if (result.itemsAvailable) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
showBottomSheet = true,
|
||||
isVaultSyncing = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isVaultSyncing = false,
|
||||
dialogState = ImportLoginsState.DialogState.Error(
|
||||
R.string.no_logins_were_imported.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -215,9 +226,10 @@ data class ImportLoginsState(
|
|||
/**
|
||||
* Show a dialog with an error message.
|
||||
*/
|
||||
data object Error : DialogState() {
|
||||
data class Error(
|
||||
override val message: Text = R.string.generic_error_message.asText(),
|
||||
) : DialogState() {
|
||||
override val title: Text? = null
|
||||
override val message: Text = R.string.generic_error_message.asText()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1072,4 +1072,5 @@ Do you want to switch to this account?</string>
|
|||
<string name="manage_your_logins_from_anywhere_with_bitwarden_tools">Manage your logins from anywhere with Bitwarden tools for web and desktop.</string>
|
||||
<string name="bitwarden_tools">Bitwarden Tools</string>
|
||||
<string name="got_it">Got it</string>
|
||||
<string name="no_logins_were_imported">No logins were imported</string>
|
||||
</resources>
|
||||
|
|
|
@ -160,8 +160,11 @@ class VaultRepositoryTest {
|
|||
private val sendsService: SendsService = mockk()
|
||||
private val ciphersService: CiphersService = mockk()
|
||||
private val folderService: FolderService = mockk()
|
||||
private val mutableGetCiphersFlow: MutableStateFlow<List<SyncResponseJson.Cipher>> =
|
||||
MutableStateFlow(listOf(createMockCipher(1)))
|
||||
private val vaultDiskSource: VaultDiskSource = mockk {
|
||||
coEvery { resyncVaultData(any()) } just runs
|
||||
every { getCiphers(any()) } returns mutableGetCiphersFlow
|
||||
}
|
||||
private val totpCodeManager: TotpCodeManager = mockk()
|
||||
private val vaultSdkSource: VaultSdkSource = mockk {
|
||||
|
@ -4393,39 +4396,77 @@ class VaultRepositoryTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `syncForResult should return result`() = runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val userId = "mockId-1"
|
||||
val mockSyncResponse = createMockSyncResponse(number = 1)
|
||||
coEvery {
|
||||
syncService.sync()
|
||||
} returns mockSyncResponse.asSuccess()
|
||||
coEvery {
|
||||
vaultSdkSource.initializeOrganizationCrypto(
|
||||
userId = userId,
|
||||
request = InitOrgCryptoRequest(
|
||||
organizationKeys = createMockOrganizationKeys(1),
|
||||
),
|
||||
)
|
||||
} returns InitializeCryptoResult.Success.asSuccess()
|
||||
coEvery {
|
||||
vaultDiskSource.replaceVaultData(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
vault = mockSyncResponse,
|
||||
)
|
||||
} just runs
|
||||
fun `syncForResult should return success result with itemsAvailable = true when sync succeeds and ciphers list is not empty`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val userId = "mockId-1"
|
||||
val mockSyncResponse = createMockSyncResponse(number = 1)
|
||||
coEvery {
|
||||
syncService.sync()
|
||||
} returns mockSyncResponse.asSuccess()
|
||||
coEvery {
|
||||
vaultSdkSource.initializeOrganizationCrypto(
|
||||
userId = userId,
|
||||
request = InitOrgCryptoRequest(
|
||||
organizationKeys = createMockOrganizationKeys(1),
|
||||
),
|
||||
)
|
||||
} returns InitializeCryptoResult.Success.asSuccess()
|
||||
coEvery {
|
||||
vaultDiskSource.replaceVaultData(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
vault = mockSyncResponse,
|
||||
)
|
||||
} just runs
|
||||
|
||||
every {
|
||||
settingsDiskSource.storeLastSyncTime(
|
||||
MOCK_USER_STATE.activeUserId,
|
||||
clock.instant(),
|
||||
)
|
||||
} just runs
|
||||
every {
|
||||
settingsDiskSource.storeLastSyncTime(
|
||||
MOCK_USER_STATE.activeUserId,
|
||||
clock.instant(),
|
||||
)
|
||||
} just runs
|
||||
|
||||
val syncResult = vaultRepository.syncForResult()
|
||||
assertEquals(SyncVaultDataResult.Success, syncResult)
|
||||
}
|
||||
val syncResult = vaultRepository.syncForResult()
|
||||
assertEquals(SyncVaultDataResult.Success(itemsAvailable = true), syncResult)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `syncForResult should return success result with itemsAvailable = false when sync succeeds and ciphers list is empty`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val userId = "mockId-1"
|
||||
val mockSyncResponse = createMockSyncResponse(number = 1).copy(ciphers = emptyList())
|
||||
coEvery {
|
||||
syncService.sync()
|
||||
} returns mockSyncResponse.asSuccess()
|
||||
coEvery {
|
||||
vaultSdkSource.initializeOrganizationCrypto(
|
||||
userId = userId,
|
||||
request = InitOrgCryptoRequest(
|
||||
organizationKeys = createMockOrganizationKeys(1),
|
||||
),
|
||||
)
|
||||
} returns InitializeCryptoResult.Success.asSuccess()
|
||||
coEvery {
|
||||
vaultDiskSource.replaceVaultData(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
vault = mockSyncResponse,
|
||||
)
|
||||
} just runs
|
||||
|
||||
every {
|
||||
settingsDiskSource.storeLastSyncTime(
|
||||
MOCK_USER_STATE.activeUserId,
|
||||
clock.instant(),
|
||||
)
|
||||
} just runs
|
||||
|
||||
val syncResult = vaultRepository.syncForResult()
|
||||
assertEquals(SyncVaultDataResult.Success(itemsAvailable = false), syncResult)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `syncForResult should return error when getAccountRevisionDateMillis fails`() =
|
||||
|
@ -4456,6 +4497,30 @@ class VaultRepositoryTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `syncForResult when the last sync time is more recent than the revision date should return result from disk source data`() =
|
||||
runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
every {
|
||||
settingsDiskSource.getLastSyncTime(userId = userId)
|
||||
} returns clock.instant().plus(2, ChronoUnit.MINUTES)
|
||||
mutableGetCiphersFlow.update { emptyList() }
|
||||
val result = vaultRepository.syncForResult()
|
||||
assertEquals(
|
||||
SyncVaultDataResult.Success(itemsAvailable = false),
|
||||
result,
|
||||
)
|
||||
verify(exactly = 1) {
|
||||
settingsDiskSource.storeLastSyncTime(
|
||||
userId = userId,
|
||||
lastSyncTime = clock.instant(),
|
||||
)
|
||||
}
|
||||
coVerify(exactly = 0) { syncService.sync() }
|
||||
}
|
||||
|
||||
//region Helper functions
|
||||
|
||||
/**
|
||||
|
|
|
@ -17,9 +17,11 @@ import androidx.compose.ui.test.performScrollTo
|
|||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.printToLog
|
||||
import androidx.core.net.toUri
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
|
||||
import io.mockk.every
|
||||
|
@ -342,7 +344,7 @@ class ImportLoginsScreenTest : BaseComposeTest() {
|
|||
fun `Error dialog is displayed when dialog state is Error`() {
|
||||
mutableImportLoginsStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ImportLoginsState.DialogState.Error,
|
||||
dialogState = ImportLoginsState.DialogState.Error(),
|
||||
)
|
||||
}
|
||||
composeTestRule
|
||||
|
@ -371,6 +373,41 @@ class ImportLoginsScreenTest : BaseComposeTest() {
|
|||
verifyActionSent(ImportLoginsAction.FailedSyncAcknowledged)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error dialog is displayed when dialog state is Error for no logins`() {
|
||||
mutableImportLoginsStateFlow.tryEmit(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = ImportLoginsState.DialogState.Error(
|
||||
message = R.string.no_logins_were_imported.asText(),
|
||||
),
|
||||
),
|
||||
)
|
||||
composeTestRule
|
||||
.onNode(isDialog())
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText(
|
||||
text = "No logins were imported",
|
||||
useUnmergedTree = true,
|
||||
)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Try again")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
verifyActionSent(ImportLoginsAction.RetryVaultSync)
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
verifyActionSent(ImportLoginsAction.FailedSyncAcknowledged)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success bottom sheet is shown when state is updated`() {
|
||||
mutableImportLoginsStateFlow.update {
|
||||
|
|
|
@ -2,9 +2,11 @@ package com.x8bit.bitwarden.ui.vault.feature.importlogins
|
|||
|
||||
import app.cash.turbine.test
|
||||
import app.cash.turbine.turbineScope
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
|
@ -17,7 +19,7 @@ import org.junit.jupiter.api.Test
|
|||
class ImportLoginsViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val vaultRepository: VaultRepository = mockk() {
|
||||
coEvery { syncForResult() } returns SyncVaultDataResult.Success
|
||||
coEvery { syncForResult() } returns SyncVaultDataResult.Success(itemsAvailable = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -278,7 +280,9 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
|
|||
viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress)
|
||||
viewModel.stateFlow.test {
|
||||
assertNotNull(awaitItem().dialogState)
|
||||
coEvery { vaultRepository.syncForResult() } returns SyncVaultDataResult.Success
|
||||
coEvery { vaultRepository.syncForResult() } returns SyncVaultDataResult.Success(
|
||||
itemsAvailable = true,
|
||||
)
|
||||
viewModel.trySendAction(ImportLoginsAction.RetryVaultSync)
|
||||
assertEquals(
|
||||
ImportLoginsState(
|
||||
|
@ -309,6 +313,41 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `MoveToSyncInProgress should set no items imported error dialog state when sync succeeds but no items are available`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
vaultRepository.syncForResult()
|
||||
} returns SyncVaultDataResult.Success(itemsAvailable = false)
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
DEFAULT_STATE,
|
||||
awaitItem(),
|
||||
)
|
||||
viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress)
|
||||
assertEquals(
|
||||
ImportLoginsState(
|
||||
dialogState = null,
|
||||
viewState = ImportLoginsState.ViewState.InitialContent,
|
||||
isVaultSyncing = true,
|
||||
showBottomSheet = false,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
ImportLoginsState(
|
||||
dialogState = ImportLoginsState.DialogState.Error(R.string.no_logins_were_imported.asText()),
|
||||
viewState = ImportLoginsState.ViewState.InitialContent,
|
||||
isVaultSyncing = false,
|
||||
showBottomSheet = false,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SyncVaultDataResult Error should remove loading state and show error dialog`() = runTest {
|
||||
coEvery { vaultRepository.syncForResult() } returns SyncVaultDataResult.Error(Exception())
|
||||
|
@ -316,7 +355,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
|
|||
viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress)
|
||||
assertEquals(
|
||||
ImportLoginsState(
|
||||
dialogState = ImportLoginsState.DialogState.Error,
|
||||
dialogState = ImportLoginsState.DialogState.Error(),
|
||||
viewState = ImportLoginsState.ViewState.InitialContent,
|
||||
isVaultSyncing = false,
|
||||
showBottomSheet = false,
|
||||
|
|
Loading…
Add table
Reference in a new issue