BIT-279, BIT-1201: Storage, retrieval, and clearing implementation for password history (#416)

This commit is contained in:
joshua-livefront 2023-12-19 14:42:47 -05:00 committed by Álison Fernandes
parent 39e285fff8
commit 27140bf02c
9 changed files with 357 additions and 71 deletions

View file

@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import java.time.Instant
import javax.inject.Singleton
/**
@ -67,7 +68,11 @@ class GeneratorRepositoryImpl(
}
.onEach { encryptedPasswordHistoryListResult ->
mutablePasswordHistoryStateFlow.value = encryptedPasswordHistoryListResult.fold(
onSuccess = { LocalDataState.Loaded(it) },
onSuccess = {
LocalDataState.Loaded(
it.sortedByDescending { history -> history.lastUsedDate },
)
},
onFailure = { LocalDataState.Error(it) },
)
}
@ -78,7 +83,14 @@ class GeneratorRepositoryImpl(
generatorSdkSource
.generatePassword(passwordGeneratorRequest)
.fold(
onSuccess = { GeneratedPasswordResult.Success(it) },
onSuccess = { generatedPassword ->
val passwordHistoryView = PasswordHistoryView(
password = generatedPassword,
lastUsedDate = Instant.now(),
)
storePasswordHistory(passwordHistoryView)
GeneratedPasswordResult.Success(generatedPassword)
},
onFailure = { GeneratedPasswordResult.InvalidRequest },
)
@ -88,7 +100,14 @@ class GeneratorRepositoryImpl(
generatorSdkSource
.generatePassphrase(passphraseGeneratorRequest)
.fold(
onSuccess = { GeneratedPassphraseResult.Success(it) },
onSuccess = { generatedPassphrase ->
val passwordHistoryView = PasswordHistoryView(
password = generatedPassphrase,
lastUsedDate = Instant.now(),
)
storePasswordHistory(passwordHistoryView)
GeneratedPassphraseResult.Success(generatedPassphrase)
},
onFailure = { GeneratedPassphraseResult.InvalidRequest },
)

View file

@ -28,11 +28,14 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
@ -48,6 +51,7 @@ import kotlinx.collections.immutable.persistentListOf
fun PasswordHistoryScreen(
onNavigateBack: () -> Unit,
viewModel: PasswordHistoryViewModel = hiltViewModel(),
clipboardManager: ClipboardManager = LocalClipboardManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
@ -56,6 +60,11 @@ fun PasswordHistoryScreen(
EventsEffect(viewModel = viewModel) { event ->
when (event) {
PasswordHistoryEvent.NavigateBack -> onNavigateBack.invoke()
is PasswordHistoryEvent.CopyTextToClipboard -> {
clipboardManager.setText(event.text.toAnnotatedString())
}
is PasswordHistoryEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
@ -189,7 +198,7 @@ private fun PasswordHistoryError(
contentAlignment = Alignment.Center,
) {
Text(
text = state.message,
text = state.message.invoke(),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.navigationBarsPadding())

View file

@ -1,9 +1,22 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import android.os.Parcelable
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.PasswordHistoryView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
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.tools.feature.generator.passwordhistory.PasswordHistoryState.GeneratedPassword
import com.x8bit.bitwarden.ui.tools.feature.generator.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.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -12,16 +25,61 @@ import javax.inject.Inject
*/
@HiltViewModel
@Suppress("TooManyFunctions")
class PasswordHistoryViewModel @Inject constructor() :
BaseViewModel<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>(
initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading),
) {
class PasswordHistoryViewModel @Inject constructor(
private val generatorRepository: GeneratorRepository,
) : BaseViewModel<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>(
initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading),
) {
init {
generatorRepository
.passwordHistoryStateFlow
.map { PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: PasswordHistoryAction) {
when (action) {
PasswordHistoryAction.CloseClick -> handleCloseClick()
is PasswordHistoryAction.PasswordCopyClick -> handleCopyClick(action.password)
PasswordHistoryAction.PasswordClearClick -> handlePasswordHistoryClearClick()
is PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive -> {
handleUpdatePasswordHistoryReceive(action)
}
}
}
private fun handleUpdatePasswordHistoryReceive(
action: PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive,
) {
val newState = when (val state = action.state) {
is LocalDataState.Loading -> PasswordHistoryState.ViewState.Loading
is LocalDataState.Error -> {
PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText())
}
is LocalDataState.Loaded -> {
val passwords = state.data.map { passwordHistoryView ->
GeneratedPassword(
password = passwordHistoryView.password,
date = passwordHistoryView.lastUsedDate.toFormattedPattern(
pattern = "MM/dd/yy h:mm a",
),
)
}
if (passwords.isEmpty()) {
PasswordHistoryState.ViewState.Empty
} else {
PasswordHistoryState.ViewState.Content(passwords)
}
}
}
mutableStateFlow.update {
it.copy(viewState = newState)
}
}
@ -32,19 +90,13 @@ class PasswordHistoryViewModel @Inject constructor() :
}
private fun handlePasswordHistoryClearClick() {
sendEvent(
event = PasswordHistoryEvent.ShowToast(
message = "Not yet implemented.",
),
)
viewModelScope.launch {
generatorRepository.clearPasswordHistory()
}
}
private fun handleCopyClick(password: GeneratedPassword) {
sendEvent(
event = PasswordHistoryEvent.ShowToast(
message = "Not yet implemented.",
),
)
sendEvent(PasswordHistoryEvent.CopyTextToClipboard(password.password))
}
}
@ -76,7 +128,7 @@ data class PasswordHistoryState(
* @property message The error message to be displayed.
*/
@Parcelize
data class Error(val message: String) : ViewState()
data class Error(val message: Text) : ViewState()
/**
* Empty state for the password history screen.
@ -122,6 +174,11 @@ sealed class PasswordHistoryEvent {
* Event to navigate back to the previous screen.
*/
data object NavigateBack : PasswordHistoryEvent()
/**
* Copies text to the clipboard.
*/
data class CopyTextToClipboard(val text: String) : PasswordHistoryEvent()
}
/**
@ -145,4 +202,17 @@ sealed class PasswordHistoryAction {
* Action when the close button is clicked.
*/
data object CloseClick : PasswordHistoryAction()
/**
* Models actions that the [PasswordHistoryViewModel] itself might send.
*/
sealed class Internal : PasswordHistoryAction() {
/**
* Indicates a password history update is received.
*/
data class UpdatePasswordHistoryReceive(
val state: LocalDataState<List<PasswordHistoryView>>,
) : Internal()
}
}

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.util
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.TimeZone
/**
* Converts the [Instant] to a formatted string based on the provided pattern and time zone.
*/
fun Instant.toFormattedPattern(
pattern: String,
zone: ZoneId = TimeZone.getDefault().toZoneId(),
): String {
val formatter = DateTimeFormatter.ofPattern(pattern).withZone(zone)
return formatter.format(this)
}

View file

@ -25,20 +25,21 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassph
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
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.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Instant
@ -50,6 +51,7 @@ class GeneratorRepositoryTest {
private val generatorDiskSource: GeneratorDiskSource = mockk()
private val authDiskSource: AuthDiskSource = mockk {
every { userStateFlow } returns mutableUserStateFlow
every { userState } returns null
}
private val passwordHistoryDiskSource: PasswordHistoryDiskSource = mockk()
private val vaultSdkSource: VaultSdkSource = mockk()
@ -64,13 +66,19 @@ class GeneratorRepositoryTest {
dispatcherManager = dispatcherManager,
)
@BeforeEach
fun setUp() {
clearMocks(generatorSdkSource)
@AfterEach
fun tearDown() {
unmockkStatic(Instant::class)
}
@Test
fun `generatePassword should emit Success result with the generated password`() = runTest {
fun `generatePassword should emit Success result and store the generated password`() = runTest {
val fixedInstant = Instant.parse("2021-01-01T00:00:00Z")
mockkStatic(Instant::class)
every { Instant.now() } returns fixedInstant
val userId = "testUserId"
val request = PasswordGeneratorRequest(
lowercase = true,
uppercase = true,
@ -83,15 +91,30 @@ class GeneratorRepositoryTest {
minNumber = null,
minSpecial = null,
)
val expectedResult = "GeneratedPassword123!"
coEvery {
generatorSdkSource.generatePassword(request)
} returns Result.success(expectedResult)
val generatedPassword = "GeneratedPassword123!"
val encryptedPasswordHistory =
PasswordHistory(password = generatedPassword, lastUsedDate = Instant.now())
coEvery { authDiskSource.userState?.activeUserId } returns userId
coEvery { generatorSdkSource.generatePassword(request) } returns
Result.success(generatedPassword)
coEvery { vaultSdkSource.encryptPasswordHistory(any()) } returns
Result.success(encryptedPasswordHistory)
coEvery { passwordHistoryDiskSource.insertPasswordHistory(any()) } just runs
val result = repository.generatePassword(request)
assertEquals(expectedResult, (result as GeneratedPasswordResult.Success).generatedString)
assertEquals(generatedPassword, (result as GeneratedPasswordResult.Success).generatedString)
coVerify { generatorSdkSource.generatePassword(request) }
coVerify {
passwordHistoryDiskSource.insertPasswordHistory(
encryptedPasswordHistory.toPasswordHistoryEntity(userId),
)
}
}
@Test
@ -118,23 +141,47 @@ class GeneratorRepositoryTest {
}
@Test
fun `generatePassphrase should emit Success result with the generated passphrase`() = runTest {
val request = PassphraseGeneratorRequest(
numWords = 5.toUByte(),
capitalize = true,
includeNumber = true,
wordSeparator = '-'.toString(),
)
val expectedResult = "Generated-Passphrase-123!"
coEvery {
generatorSdkSource.generatePassphrase(request)
} returns Result.success(expectedResult)
fun `generatePassphrase should emit Success result and store the generated passphrase`() =
runTest {
val fixedInstant = Instant.parse("2021-01-01T00:00:00Z")
mockkStatic(Instant::class)
every { Instant.now() } returns fixedInstant
val result = repository.generatePassphrase(request)
val userId = "testUserId"
val request = PassphraseGeneratorRequest(
numWords = 5.toUByte(),
capitalize = true,
includeNumber = true,
wordSeparator = "-",
)
val generatedPassphrase = "Generated-Passphrase-123"
val encryptedPasswordHistory =
PasswordHistory(password = generatedPassphrase, lastUsedDate = Instant.now())
assertEquals(expectedResult, (result as GeneratedPassphraseResult.Success).generatedString)
coVerify { generatorSdkSource.generatePassphrase(request) }
}
coEvery { authDiskSource.userState?.activeUserId } returns userId
coEvery { generatorSdkSource.generatePassphrase(request) } returns
Result.success(generatedPassphrase)
coEvery { vaultSdkSource.encryptPasswordHistory(any()) } returns
Result.success(encryptedPasswordHistory)
coEvery { passwordHistoryDiskSource.insertPasswordHistory(any()) } just runs
val result = repository.generatePassphrase(request)
assertEquals(
generatedPassphrase,
(result as GeneratedPassphraseResult.Success).generatedString,
)
coVerify { generatorSdkSource.generatePassphrase(request) }
coVerify { vaultSdkSource.encryptPasswordHistory(any()) }
coVerify {
passwordHistoryDiskSource.insertPasswordHistory(
encryptedPasswordHistory.toPasswordHistoryEntity(userId),
)
}
}
@Suppress("MaxLineLength")
@Test
@ -278,25 +325,25 @@ class GeneratorRepositoryTest {
val encryptedPasswordHistoryEntities = listOf(
PasswordHistoryEntity(
userId = USER_STATE.activeUserId,
encryptedPassword = "encryptedPassword1",
generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(),
encryptedPassword = "encryptedPassword2",
generatedDateTimeMs = Instant.parse("2021-01-02T00:00:00Z").toEpochMilli(),
),
PasswordHistoryEntity(
userId = USER_STATE.activeUserId,
encryptedPassword = "encryptedPassword2",
generatedDateTimeMs = Instant.parse("2021-01-02T00:00:00Z").toEpochMilli(),
encryptedPassword = "encryptedPassword1",
generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(),
),
)
val decryptedPasswordHistoryList = listOf(
PasswordHistoryView(
password = "password1",
lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"),
),
PasswordHistoryView(
password = "password2",
lastUsedDate = Instant.parse("2021-01-02T00:00:00Z"),
),
PasswordHistoryView(
password = "password1",
lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"),
),
)
coEvery {

View file

@ -74,4 +74,19 @@ class FakeGeneratorRepository : GeneratorRepository {
fun setMockGeneratePassphraseResult(result: GeneratedPassphraseResult) {
generatePassphraseResult = result
}
/**
* Checks if a specific password is in the history.
*/
fun isPasswordStoredInHistory(password: String): Boolean {
val passwordHistoryList = mutablePasswordHistoryStateFlow.value.data.orEmpty()
return passwordHistoryList.any { it.password == password }
}
/**
* Emits specified state to the passwordHistoryStateFlow.
*/
fun emitPasswordHistoryState(state: LocalDataState<List<PasswordHistoryView>>) {
mutablePasswordHistoryStateFlow.value = state
}
}

View file

@ -5,6 +5,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -49,7 +50,9 @@ class PasswordHistoryScreenTest : BaseComposeTest() {
@Test
fun `Error state should display error message`() {
val errorMessage = "Error occurred"
updateState(PasswordHistoryState(PasswordHistoryState.ViewState.Error(errorMessage)))
updateState(
PasswordHistoryState(PasswordHistoryState.ViewState.Error(errorMessage.asText())),
)
composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed()
}

View file

@ -1,10 +1,18 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import app.cash.turbine.test
import com.bitwarden.core.PasswordHistoryView
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.tools.feature.generator.util.toFormattedPattern
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Instant
class PasswordHistoryViewModelTest : BaseViewModelTest() {
@ -18,6 +26,77 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `when repository emits Loading state the state updates correctly`() = runTest {
val fakeRepository = FakeGeneratorRepository().apply {
emitPasswordHistoryState(LocalDataState.Loading)
}
val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository)
viewModel.stateFlow.test {
val expectedState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading)
val actualState = awaitItem()
assertEquals(expectedState, actualState)
}
}
@Test
fun `when repository emits Error state the state updates correctly`() = runTest {
val fakeRepository = FakeGeneratorRepository().apply {
emitPasswordHistoryState(LocalDataState.Error(Exception("An error has occurred.")))
}
val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository)
viewModel.stateFlow.test {
val expectedState = PasswordHistoryState(
PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText()),
)
val actualState = awaitItem()
assertEquals(expectedState, actualState)
}
}
@Test
fun `when repository emits Empty state the state updates correctly`() = runTest {
val fakeRepository = FakeGeneratorRepository().apply {
emitPasswordHistoryState(LocalDataState.Loaded(emptyList()))
}
val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository)
viewModel.stateFlow.test {
val expectedState = PasswordHistoryState(PasswordHistoryState.ViewState.Empty)
val actualState = awaitItem()
assertEquals(expectedState, actualState)
}
}
@Test
fun `when password history updates the state updates correctly`() = runTest {
val fakeRepository = FakeGeneratorRepository()
val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository)
val passwordHistoryView = PasswordHistoryView("password", Instant.now())
fakeRepository.storePasswordHistory(passwordHistoryView)
val expectedState = PasswordHistoryState(
viewState = PasswordHistoryState.ViewState.Content(
passwords = listOf(
PasswordHistoryState.GeneratedPassword(
password = "password",
date = passwordHistoryView.lastUsedDate.toFormattedPattern(
pattern = "MM/dd/yy h:mm a",
),
),
),
),
)
viewModel.stateFlow.test {
val actualState = awaitItem()
assertEquals(expectedState, actualState)
}
}
@Test
fun `CloseClick action should emit NavigateBack event`() = runTest {
val viewModel = createViewModel()
@ -29,37 +108,44 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() {
}
@Test
fun `PasswordCopyClick action should emit password copied ShowToast event`() = runTest {
fun `PasswordCopyClick action should emit CopyTextToClipboard event`() = runTest {
val viewModel = createViewModel()
val generatedPassword = PasswordHistoryState.GeneratedPassword(
password = "testPassword",
date = "01/01/23",
)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
PasswordHistoryAction.PasswordCopyClick(
PasswordHistoryState.GeneratedPassword(password = "Password", date = "Date"),
),
PasswordHistoryAction.PasswordCopyClick(generatedPassword),
)
assertEquals(
PasswordHistoryEvent.CopyTextToClipboard(generatedPassword.password),
awaitItem(),
)
assertEquals(PasswordHistoryEvent.ShowToast("Not yet implemented."), awaitItem())
}
}
@Test
fun `PasswordClearClick action should emit password history cleared ShowToast event`() =
runTest {
val viewModel = createViewModel()
fun `PasswordClearClick action should update to Empty ViewState`() = runTest {
val fakeRepository = FakeGeneratorRepository()
val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(PasswordHistoryAction.PasswordClearClick)
assertEquals(
PasswordHistoryEvent.ShowToast("Not yet implemented."),
awaitItem(),
)
}
}
val passwordHistoryView = PasswordHistoryView("password", Instant.now())
fakeRepository.storePasswordHistory(passwordHistoryView)
viewModel.actionChannel.trySend(PasswordHistoryAction.PasswordClearClick)
assertTrue(fakeRepository.passwordHistoryStateFlow.value is LocalDataState.Loaded)
assertTrue(
(fakeRepository.passwordHistoryStateFlow.value as LocalDataState.Loaded).data.isEmpty(),
)
}
//region Helper Functions
private fun createViewModel(): PasswordHistoryViewModel {
return PasswordHistoryViewModel()
return PasswordHistoryViewModel(generatorRepository = FakeGeneratorRepository())
}
//endregion Helper Functions

View file

@ -0,0 +1,20 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.util
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.Instant
import java.time.ZoneId
class InstantExtensionsTest {
@Test
fun `toFormattedPattern should return correctly formatted string`() {
val instant = Instant.parse("2023-12-10T15:30:00Z")
val pattern = "MM/dd/yyyy hh:mm a"
val zone = ZoneId.of("UTC")
val expectedFormattedString = "12/10/2023 03:30 PM"
val formattedString = instant.toFormattedPattern(pattern, zone)
assertEquals(expectedFormattedString, formattedString)
}
}