BIT-543: Add Remember Me functionality to Landing Screen (#104)

Co-authored-by: Brian Yencho <brian@livefront.com>
This commit is contained in:
Andrew Haisting
2023-10-10 13:22:41 -05:00
committed by GitHub
parent ff77c96767
commit 56a8674958
12 changed files with 306 additions and 28 deletions

View File

@@ -0,0 +1,34 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class AuthDiskSourceTest {
private val fakeSharedPreferences = FakeSharedPreferences()
private val authDiskSource = AuthDiskSourceImpl(
sharedPreferences = fakeSharedPreferences,
)
@Test
fun `rememberedEmailAddress should pull from and update SharedPreferences`() {
val rememberedEmailKey = "bwPreferencesStorage:rememberedEmail"
// Shared preferences and the repository start with the same value.
assertNull(authDiskSource.rememberedEmailAddress)
assertNull(fakeSharedPreferences.getString(rememberedEmailKey, null))
// Updating the repository updates shared preferences
authDiskSource.rememberedEmailAddress = "remembered@gmail.com"
assertEquals(
"remembered@gmail.com",
fakeSharedPreferences.getString(rememberedEmailKey, null),
)
// Update SharedPreferences updates the repository
fakeSharedPreferences.edit().putString(rememberedEmailKey, null).apply()
assertNull(authDiskSource.rememberedEmailAddress)
}
}

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.repository
import app.cash.turbine.test
import com.bitwarden.core.Kdf
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
@@ -19,6 +20,7 @@ import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
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
@@ -27,6 +29,7 @@ class AuthRepositoryTest {
private val accountsService: AccountsService = mockk()
private val identityService: IdentityService = mockk()
private val authInterceptor = mockk<AuthTokenInterceptor>()
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val mockBitwardenSdk = mockk<Client> {
coEvery {
auth().hashPassword(
@@ -41,6 +44,7 @@ class AuthRepositoryTest {
accountsService = accountsService,
identityService = identityService,
bitwardenSdkClient = mockBitwardenSdk,
authDiskSource = fakeAuthDiskSource,
authTokenInterceptor = authInterceptor,
)
@@ -49,6 +53,21 @@ class AuthRepositoryTest {
clearMocks(identityService, accountsService, authInterceptor)
}
@Test
fun `rememberedEmailAddress should pull from and update AuthDiskSource`() {
// AuthDiskSource and the repository start with the same value.
assertNull(repository.rememberedEmailAddress)
assertNull(fakeAuthDiskSource.rememberedEmailAddress)
// Updating the repository updates AuthDiskSource
repository.rememberedEmailAddress = "remembered@gmail.com"
assertEquals("remembered@gmail.com", fakeAuthDiskSource.rememberedEmailAddress)
// Updating AuthDiskSource updates the repository
fakeAuthDiskSource.rememberedEmailAddress = null
assertNull(repository.rememberedEmailAddress)
}
@Test
fun `login when pre login fails should return Error with no message`() = runTest {
coEvery {
@@ -197,3 +216,7 @@ class AuthRepositoryTest {
)
}
}
private class FakeAuthDiskSource : AuthDiskSource {
override var rememberedEmailAddress: String? = null
}

View File

@@ -0,0 +1,101 @@
package com.x8bit.bitwarden.data.platform.base
import android.content.SharedPreferences
/**
* A faked implementation of [SharedPreferences] that is backed by an internal, memory-based map.
*/
class FakeSharedPreferences : SharedPreferences {
private val sharedPreferences: MutableMap<String, Any?> = mutableMapOf()
override fun contains(key: String): Boolean =
sharedPreferences.containsKey(key)
override fun edit(): SharedPreferences.Editor = Editor()
override fun getAll(): Map<String, *> = sharedPreferences
override fun getBoolean(key: String, defaultValue: Boolean): Boolean =
getValue(key, defaultValue)
override fun getFloat(key: String, defaultValue: Float): Float =
getValue(key, defaultValue)
override fun getInt(key: String, defaultValue: Int): Int =
getValue(key, defaultValue)
override fun getLong(key: String, defaultValue: Long): Long =
getValue(key, defaultValue)
override fun getString(key: String, defaultValue: String?): String? =
getValue(key, defaultValue)
override fun getStringSet(key: String, defaultValue: Set<String>?): Set<String>? =
getValue(key, defaultValue)
override fun registerOnSharedPreferenceChangeListener(
listener: SharedPreferences.OnSharedPreferenceChangeListener,
) {
throw NotImplementedError(
"registerOnSharedPreferenceChangeListener is not currently implemented.",
)
}
override fun unregisterOnSharedPreferenceChangeListener(
listener: SharedPreferences.OnSharedPreferenceChangeListener,
) {
throw NotImplementedError(
"unregisterOnSharedPreferenceChangeListener is not currently implemented.",
)
}
private inline fun <reified T> getValue(
key: String,
defaultValue: T,
): T = sharedPreferences[key] as? T ?: defaultValue
inner class Editor : SharedPreferences.Editor {
private val pendingSharedPreferences = sharedPreferences.toMutableMap()
override fun apply() {
sharedPreferences.apply {
clear()
putAll(pendingSharedPreferences)
}
}
override fun clear(): SharedPreferences.Editor =
apply { pendingSharedPreferences.clear() }
override fun commit(): Boolean {
apply()
return true
}
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor =
putValue(key, value)
override fun putFloat(key: String, value: Float): SharedPreferences.Editor =
putValue(key, value)
override fun putInt(key: String, value: Int): SharedPreferences.Editor =
putValue(key, value)
override fun putLong(key: String, value: Long): SharedPreferences.Editor =
putValue(key, value)
override fun putString(key: String, value: String?): SharedPreferences.Editor =
putValue(key, value)
override fun putStringSet(key: String, value: Set<String>?): SharedPreferences.Editor =
putValue(key, value)
override fun remove(key: String): SharedPreferences.Editor =
apply { pendingSharedPreferences.remove(key) }
private inline fun <reified T> putValue(
key: String,
value: T,
): SharedPreferences.Editor = apply { pendingSharedPreferences[key] = value }
}
}

View File

@@ -3,20 +3,37 @@ package com.x8bit.bitwarden.ui.auth.feature.landing
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class LandingViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct`() = runTest {
val viewModel = LandingViewModel(SavedStateHandle())
fun `initial state should be correct when there is no remembered email`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Test
fun `initial state should be correct when there is a remembered email`() = runTest {
val rememberedEmail = "remembered@gmail.com"
val viewModel = createViewModel(rememberedEmail = rememberedEmail)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
emailInput = rememberedEmail,
isContinueButtonEnabled = true,
isRememberMeEnabled = true,
),
awaitItem(),
)
}
}
@Test
fun `initial state should pull from saved state handle when present`() = runTest {
val expectedState = DEFAULT_STATE.copy(
@@ -25,7 +42,7 @@ class LandingViewModelTest : BaseViewModelTest() {
isRememberMeEnabled = true,
)
val handle = SavedStateHandle(mapOf("state" to expectedState))
val viewModel = LandingViewModel(handle)
val viewModel = createViewModel(savedStateHandle = handle)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
@@ -33,7 +50,7 @@ class LandingViewModelTest : BaseViewModelTest() {
@Test
fun `ContinueButtonClick should emit NavigateToLogin`() = runTest {
val viewModel = LandingViewModel(SavedStateHandle())
val viewModel = createViewModel()
viewModel.trySendAction(LandingAction.EmailInputChanged("input"))
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.ContinueButtonClick)
@@ -46,7 +63,7 @@ class LandingViewModelTest : BaseViewModelTest() {
@Test
fun `ContinueButtonClick with empty input should do nothing`() = runTest {
val viewModel = LandingViewModel(SavedStateHandle())
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.ContinueButtonClick)
}
@@ -54,7 +71,7 @@ class LandingViewModelTest : BaseViewModelTest() {
@Test
fun `CreateAccountClick should emit NavigateToCreateAccount`() = runTest {
val viewModel = LandingViewModel(SavedStateHandle())
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.CreateAccountClick)
assertEquals(
@@ -66,7 +83,7 @@ class LandingViewModelTest : BaseViewModelTest() {
@Test
fun `RememberMeToggle should update value of isRememberMeToggled`() = runTest {
val viewModel = LandingViewModel(SavedStateHandle())
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.RememberMeToggle(true))
assertEquals(
@@ -79,7 +96,7 @@ class LandingViewModelTest : BaseViewModelTest() {
@Test
fun `EmailInputUpdated should update value of email input and continue button state`() =
runTest {
val viewModel = LandingViewModel(SavedStateHandle())
val viewModel = createViewModel()
viewModel.stateFlow.test {
// Ignore initial state
awaitItem()
@@ -109,7 +126,7 @@ class LandingViewModelTest : BaseViewModelTest() {
@Test
fun `RegionOptionSelect should update value of selected region`() = runTest {
val inputRegion = LandingState.RegionOption.BITWARDEN_EU
val viewModel = LandingViewModel(SavedStateHandle())
val viewModel = createViewModel()
viewModel.stateFlow.test {
awaitItem()
viewModel.trySendAction(LandingAction.RegionOptionSelect(inputRegion))
@@ -120,6 +137,20 @@ class LandingViewModelTest : BaseViewModelTest() {
}
}
//region Helper methods
private fun createViewModel(
rememberedEmail: String? = null,
savedStateHandle: SavedStateHandle = SavedStateHandle(),
): LandingViewModel = LandingViewModel(
authRepository = mockk(relaxed = true) {
every { rememberedEmailAddress } returns rememberedEmail
},
savedStateHandle = savedStateHandle,
)
//endregion Helper methods
companion object {
private val DEFAULT_STATE = LandingState(
emailInput = "",