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 Álison Fernandes
parent c7ab805f91
commit 5a2a2f93f3
12 changed files with 306 additions and 28 deletions

View file

@ -0,0 +1,11 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
/**
* Primary access point for disk information.
*/
interface AuthDiskSource {
/**
* The currently persisted saved email address (or `null` if not set).
*/
var rememberedEmailAddress: String?
}

View file

@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import android.content.SharedPreferences
private const val REMEMBERED_EMAIL_ADDRESS_KEY = "bwPreferencesStorage:rememberedEmail"
/**
* Primary implementation of [AuthDiskSource].
*/
class AuthDiskSourceImpl(
private val sharedPreferences: SharedPreferences,
) : AuthDiskSource {
override var rememberedEmailAddress: String?
get() = sharedPreferences.getString(REMEMBERED_EMAIL_ADDRESS_KEY, null)
set(value) {
sharedPreferences
.edit()
.putString(REMEMBERED_EMAIL_ADDRESS_KEY, value)
.apply()
}
}

View file

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.auth.datasource.disk.di
import android.content.SharedPreferences
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSourceImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Provides persistence-related dependencies in the auth package.
*/
@Module
@InstallIn(SingletonComponent::class)
object DiskModule {
@Provides
@Singleton
fun provideAuthDiskSource(
sharedPreferences: SharedPreferences,
): AuthDiskSource =
AuthDiskSourceImpl(sharedPreferences = sharedPreferences)
}

View file

@ -21,6 +21,11 @@ interface AuthRepository {
*/
val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult>
/**
* The currently persisted saved email address (or `null` if not set).
*/
var rememberedEmailAddress: String?
/**
* Attempt to login with the given email and password. Updated access token will be reflected
* in [authStateFlow].

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository
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.GetTokenResponseJson.CaptchaRequired
@ -21,6 +22,8 @@ import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
private const val REMEMBERED_EMAIL_ADDRESS_KEY = "bwPreferencesStorage:rememberedEmail"
/**
* Default implementation of [AuthRepository].
*/
@ -29,6 +32,7 @@ class AuthRepositoryImpl @Inject constructor(
private val accountsService: AccountsService,
private val identityService: IdentityService,
private val bitwardenSdkClient: Client,
private val authDiskSource: AuthDiskSource,
private val authTokenInterceptor: AuthTokenInterceptor,
) : AuthRepository {
@ -40,6 +44,12 @@ class AuthRepositoryImpl @Inject constructor(
override val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult> =
mutableCaptchaTokenFlow.asSharedFlow()
override var rememberedEmailAddress: String?
get() = authDiskSource.rememberedEmailAddress
set(value) {
authDiskSource.rememberedEmailAddress = value
}
override suspend fun login(
email: String,
password: String,

View file

@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.platform.datasource.di
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Provides dependencies related to encryption / decryption / secure generation.
*/
@Module
@InstallIn(SingletonComponent::class)
object PreferenceModule {
@Provides
@Singleton
fun provideDefaultSharedPreferences(
application: Application,
): SharedPreferences = application.getSharedPreferences(null, Context.MODE_PRIVATE)
}

View file

@ -32,10 +32,8 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
@ -43,7 +41,6 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* The top level composable for the Landing screen.
@ -234,15 +231,3 @@ private fun RegionSelector(
}
}
}
@Preview
@Composable
private fun LandingScreen_preview() {
BitwardenTheme {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = { _, _ -> },
viewModel = LandingViewModel(SavedStateHandle()),
)
}
}

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.auth.feature.landing
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
@ -18,13 +19,14 @@ private const val KEY_STATE = "state"
*/
@HiltViewModel
class LandingViewModel @Inject constructor(
private val authRepository: AuthRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LandingState, LandingEvent, LandingAction>(
initialState = savedStateHandle[KEY_STATE]
?: LandingState(
emailInput = "",
isContinueButtonEnabled = false,
isRememberMeEnabled = false,
emailInput = authRepository.rememberedEmailAddress.orEmpty(),
isContinueButtonEnabled = authRepository.rememberedEmailAddress != null,
isRememberMeEnabled = authRepository.rememberedEmailAddress != null,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
),
) {
@ -61,8 +63,14 @@ class LandingViewModel @Inject constructor(
if (mutableStateFlow.value.emailInput.isBlank()) {
return
}
val email = mutableStateFlow.value.emailInput
val isRememberMeEnabled = mutableStateFlow.value.isRememberMeEnabled
val selectedRegionLabel = mutableStateFlow.value.selectedRegion.label
// Update the remembered email address
authRepository.rememberedEmailAddress = email.takeUnless { !isRememberMeEnabled }
sendEvent(LandingEvent.NavigateToLogin(email, selectedRegionLabel))
}

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 = "",