mirror of
https://github.com/bitwarden/android.git
synced 2024-11-27 03:49:36 +03:00
BIT-1282: Add UI for Vault Sync (#740)
This commit is contained in:
parent
376278e97a
commit
de99c36b20
8 changed files with 191 additions and 6 deletions
|
@ -6,6 +6,7 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLang
|
|||
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Provides an API for observing and modifying settings state.
|
||||
|
@ -27,6 +28,16 @@ interface SettingsRepository {
|
|||
*/
|
||||
val appThemeStateFlow: StateFlow<AppTheme>
|
||||
|
||||
/**
|
||||
* The currently stored last time the vault was synced.
|
||||
*/
|
||||
var vaultLastSync: Instant?
|
||||
|
||||
/**
|
||||
* Tracks changes to the [vaultLastSync].
|
||||
*/
|
||||
val vaultLastSyncStateFlow: StateFlow<Instant?>
|
||||
|
||||
/**
|
||||
* The current setting for getting login item icons.
|
||||
*/
|
||||
|
|
|
@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.map
|
|||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Primary implementation of [SettingsRepository].
|
||||
|
@ -61,6 +62,26 @@ class SettingsRepositoryImpl(
|
|||
initialValue = settingsDiskSource.appTheme,
|
||||
)
|
||||
|
||||
override var vaultLastSync: Instant?
|
||||
get() = vaultLastSyncStateFlow.value
|
||||
set(value) {
|
||||
val userId = activeUserId ?: return
|
||||
settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = value)
|
||||
}
|
||||
|
||||
override val vaultLastSyncStateFlow: StateFlow<Instant?>
|
||||
get() = activeUserId
|
||||
?.let {
|
||||
settingsDiskSource
|
||||
.getLastSyncTimeFlow(userId = it)
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = settingsDiskSource.getLastSyncTime(userId = it),
|
||||
)
|
||||
}
|
||||
?: MutableStateFlow(value = null)
|
||||
|
||||
override var isIconLoadingDisabled: Boolean
|
||||
get() = settingsDiskSource.isIconLoadingDisabled ?: false
|
||||
set(value) {
|
||||
|
|
|
@ -14,6 +14,7 @@ import com.bitwarden.crypto.Kdf
|
|||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.util.isNoConnectionError
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
|
@ -78,6 +79,7 @@ import kotlinx.coroutines.flow.onStart
|
|||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
|
@ -97,9 +99,11 @@ class VaultRepositoryImpl(
|
|||
private val vaultDiskSource: VaultDiskSource,
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
private val fileManager: FileManager,
|
||||
private val vaultLockManager: VaultLockManager,
|
||||
private val totpCodeManager: TotpCodeManager,
|
||||
private val clock: Clock,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : VaultRepository,
|
||||
VaultLockManager by vaultLockManager {
|
||||
|
@ -230,6 +234,7 @@ class VaultRepositoryImpl(
|
|||
unlockVaultForOrganizationsIfNecessary(syncResponse = syncResponse)
|
||||
storeProfileData(syncResponse = syncResponse)
|
||||
vaultDiskSource.replaceVaultData(userId = userId, vault = syncResponse)
|
||||
settingsDiskSource.storeLastSyncTime(userId = userId, clock.instant())
|
||||
},
|
||||
onFailure = { throwable ->
|
||||
mutableCiphersStateFlow.update { currentState ->
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.x8bit.bitwarden.data.vault.repository.di
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService
|
||||
|
@ -16,6 +17,7 @@ import dagger.Module
|
|||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.time.Clock
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
|
@ -34,10 +36,12 @@ object VaultRepositoryModule {
|
|||
vaultDiskSource: VaultDiskSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
fileManager: FileManager,
|
||||
vaultLockManager: VaultLockManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
totpCodeManager: TotpCodeManager,
|
||||
clock: Clock,
|
||||
): VaultRepository = VaultRepositoryImpl(
|
||||
syncService = syncService,
|
||||
sendsService = sendsService,
|
||||
|
@ -45,9 +49,11 @@ object VaultRepositoryModule {
|
|||
vaultDiskSource = vaultDiskSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
authDiskSource = authDiskSource,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
fileManager = fileManager,
|
||||
vaultLockManager = vaultLockManager,
|
||||
dispatcherManager = dispatcherManager,
|
||||
totpCodeManager = totpCodeManager,
|
||||
clock = clock,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,24 +2,34 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.other
|
|||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
private const val VAULT_LAST_SYNC_TIME_PATTERN: String = "M/d/yyyy h:mm a"
|
||||
|
||||
/**
|
||||
* View model for the other screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class OtherViewModel @Inject constructor(
|
||||
private val clock: Clock,
|
||||
private val settingsRepo: SettingsRepository,
|
||||
private val vaultRepo: VaultRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
|
@ -29,16 +39,28 @@ class OtherViewModel @Inject constructor(
|
|||
allowScreenCapture = false,
|
||||
allowSyncOnRefresh = settingsRepo.getPullToRefreshEnabledFlow().value,
|
||||
clearClipboardFrequency = OtherState.ClearClipboardFrequency.DEFAULT,
|
||||
lastSyncTime = "5/14/2023 4:52 PM",
|
||||
lastSyncTime = settingsRepo
|
||||
.vaultLastSync
|
||||
?.toFormattedPattern(VAULT_LAST_SYNC_TIME_PATTERN, clock.zone)
|
||||
.orEmpty(),
|
||||
dialogState = null,
|
||||
),
|
||||
) {
|
||||
init {
|
||||
settingsRepo
|
||||
.vaultLastSyncStateFlow
|
||||
.map { OtherAction.Internal.VaultLastSyncReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: OtherAction): Unit = when (action) {
|
||||
is OtherAction.AllowScreenCaptureToggle -> handleAllowScreenCaptureToggled(action)
|
||||
is OtherAction.AllowSyncToggle -> handleAllowSyncToggled(action)
|
||||
OtherAction.BackClick -> handleBackClicked()
|
||||
is OtherAction.ClearClipboardFrequencyChange -> handleClearClipboardFrequencyChanged(action)
|
||||
OtherAction.SyncNowButtonClick -> handleSyncNowButtonClicked()
|
||||
is OtherAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
|
||||
private fun handleAllowScreenCaptureToggled(action: OtherAction.AllowScreenCaptureToggle) {
|
||||
|
@ -67,9 +89,29 @@ class OtherViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun handleSyncNowButtonClicked() {
|
||||
// TODO BIT-1282 add full support and visual feedback
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = OtherState.DialogState.Loading(R.string.syncing.asText()))
|
||||
}
|
||||
vaultRepo.sync()
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: OtherAction.Internal) {
|
||||
when (action) {
|
||||
is OtherAction.Internal.VaultLastSyncReceive -> handleVaultDataReceive(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVaultDataReceive(action: OtherAction.Internal.VaultLastSyncReceive) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
lastSyncTime = action
|
||||
.vaultLastSyncTime
|
||||
?.toFormattedPattern(VAULT_LAST_SYNC_TIME_PATTERN, clock.zone)
|
||||
.orEmpty(),
|
||||
dialogState = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -154,4 +196,16 @@ sealed class OtherAction {
|
|||
* Indicates that the user clicked the Sync Now button.
|
||||
*/
|
||||
data object SyncNowButtonClick : OtherAction()
|
||||
|
||||
/**
|
||||
* Models actions that the [OtherViewModel] itself might send.
|
||||
*/
|
||||
sealed class Internal : OtherAction() {
|
||||
/**
|
||||
* Indicates last sync time of the vault has been received.
|
||||
*/
|
||||
data class VaultLastSyncReceive(
|
||||
val vaultLastSyncTime: Instant?,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ 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.Test
|
||||
import java.time.Instant
|
||||
|
||||
class SettingsRepositoryTest {
|
||||
private val autofillManager: AutofillManager = mockk {
|
||||
|
@ -119,6 +120,36 @@ class SettingsRepositoryTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultLastSync should pull from and update SettingsDiskSource`() {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
assertNull(settingsRepository.vaultLastSync)
|
||||
val instant = Instant.ofEpochMilli(1_698_408_000_000L)
|
||||
|
||||
// Updates to the disk source change the repository value.
|
||||
fakeSettingsDiskSource.storeLastSyncTime(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
lastSyncTime = instant,
|
||||
)
|
||||
assertEquals(instant, settingsRepository.vaultLastSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vaultLastSyncStateFlow should react to changes in SettingsDiskSource`() = runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val instant = Instant.ofEpochMilli(1_698_408_000_000L)
|
||||
settingsRepository
|
||||
.vaultLastSyncStateFlow
|
||||
.test {
|
||||
assertNull(awaitItem())
|
||||
fakeSettingsDiskSource.storeLastSyncTime(
|
||||
userId = MOCK_USER_STATE.activeUserId,
|
||||
lastSyncTime = instant,
|
||||
)
|
||||
assertEquals(instant, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isIconLoadingDisabled should pull from and update SettingsDiskSource`() {
|
||||
assertFalse(settingsRepository.isIconLoadingDisabled)
|
||||
|
|
|
@ -20,6 +20,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
|
|||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
|
||||
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
|
@ -95,14 +96,20 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
|||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.net.UnknownHostException
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class VaultRepositoryTest {
|
||||
|
||||
private val clock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
private val dispatcherManager: DispatcherManager = FakeDispatcherManager()
|
||||
private val fileManager: FileManager = mockk()
|
||||
private val fakeAuthDiskSource = FakeAuthDiskSource()
|
||||
private val settingsDiskSource = mockk<SettingsDiskSource>()
|
||||
private val syncService: SyncService = mockk()
|
||||
private val sendsService: SendsService = mockk()
|
||||
private val ciphersService: CiphersService = mockk()
|
||||
|
@ -132,10 +139,12 @@ class VaultRepositoryTest {
|
|||
vaultDiskSource = vaultDiskSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
authDiskSource = fakeAuthDiskSource,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
vaultLockManager = vaultLockManager,
|
||||
dispatcherManager = dispatcherManager,
|
||||
totpCodeManager = totpCodeManager,
|
||||
fileManager = fileManager,
|
||||
clock = clock,
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
|
@ -412,6 +421,9 @@ class VaultRepositoryTest {
|
|||
vault = mockSyncResponse,
|
||||
)
|
||||
} just runs
|
||||
every {
|
||||
settingsDiskSource.storeLastSyncTime(MOCK_USER_STATE.activeUserId, clock.instant())
|
||||
} just runs
|
||||
|
||||
vaultRepository.sync()
|
||||
|
||||
|
@ -1051,6 +1063,9 @@ class VaultRepositoryTest {
|
|||
vault = mockSyncResponse,
|
||||
)
|
||||
} just runs
|
||||
every {
|
||||
settingsDiskSource.storeLastSyncTime(MOCK_USER_STATE.activeUserId, clock.instant())
|
||||
} just runs
|
||||
coEvery {
|
||||
vaultSdkSource.decryptSendList(
|
||||
userId = userId,
|
||||
|
@ -2217,6 +2232,9 @@ class VaultRepositoryTest {
|
|||
vault = mockSyncResponse,
|
||||
)
|
||||
} just runs
|
||||
every {
|
||||
settingsDiskSource.storeLastSyncTime(MOCK_USER_STATE.activeUserId, clock.instant())
|
||||
} just runs
|
||||
|
||||
val stateFlow = MutableStateFlow<DataState<List<VerificationCodeItem>>>(
|
||||
DataState.Loading,
|
||||
|
|
|
@ -2,9 +2,11 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.other
|
|||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
|
@ -14,11 +16,22 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.test.runTest
|
||||
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
|
||||
|
||||
class OtherViewModelTest : BaseViewModelTest() {
|
||||
private val clock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
private val mutablePullToRefreshStateFlow = MutableStateFlow(false)
|
||||
private val instant: Instant = Instant.parse("2023-10-26T12:00:00Z")
|
||||
private val mutableVaultLastSyncStateFlow = MutableStateFlow(instant)
|
||||
private val settingsRepository = mockk<SettingsRepository> {
|
||||
every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshStateFlow
|
||||
every { vaultLastSyncStateFlow } returns mutableVaultLastSyncStateFlow
|
||||
every { vaultLastSync } returns instant
|
||||
}
|
||||
private val vaultRepository = mockk<VaultRepository>()
|
||||
|
||||
|
@ -103,13 +116,38 @@ class OtherViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on VaultLastSyncReceive should sync repo`() = runTest {
|
||||
val newSyncTime = Instant.parse("2023-10-27T12:00:00Z")
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableVaultLastSyncStateFlow.tryEmit(newSyncTime)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
lastSyncTime = "10/27/2023 12:00 PM",
|
||||
dialogState = null,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on SyncNowButtonClick should sync repo`() = runTest {
|
||||
every { vaultRepository.sync() } just runs
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
viewModel.trySendAction(OtherAction.SyncNowButtonClick)
|
||||
expectNoEvents()
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = OtherState.DialogState.Loading(
|
||||
message = R.string.syncing.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
verify { vaultRepository.sync() }
|
||||
}
|
||||
|
@ -117,6 +155,7 @@ class OtherViewModelTest : BaseViewModelTest() {
|
|||
private fun createViewModel(
|
||||
state: OtherState? = null,
|
||||
) = OtherViewModel(
|
||||
clock = clock,
|
||||
settingsRepo = settingsRepository,
|
||||
vaultRepo = vaultRepository,
|
||||
savedStateHandle = SavedStateHandle().apply {
|
||||
|
@ -129,7 +168,7 @@ class OtherViewModelTest : BaseViewModelTest() {
|
|||
allowScreenCapture = false,
|
||||
allowSyncOnRefresh = false,
|
||||
clearClipboardFrequency = OtherState.ClearClipboardFrequency.DEFAULT,
|
||||
lastSyncTime = "5/14/2023 4:52 PM",
|
||||
lastSyncTime = "10/26/2023 12:00 PM",
|
||||
dialogState = null,
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue