diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt new file mode 100644 index 000000000..e871d29af --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt @@ -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? +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt new file mode 100644 index 000000000..5cbe8a2c3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -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() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/di/DiskModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/di/DiskModule.kt new file mode 100644 index 000000000..fb8f9a7d7 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/di/DiskModule.kt @@ -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) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 992bd68e9..452211532 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -21,6 +21,11 @@ interface AuthRepository { */ val captchaTokenResultFlow: Flow + /** + * 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]. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index d3392cf81..4f17f47a1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -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 = mutableCaptchaTokenFlow.asSharedFlow() + override var rememberedEmailAddress: String? + get() = authDiskSource.rememberedEmailAddress + set(value) { + authDiskSource.rememberedEmailAddress = value + } + override suspend fun login( email: String, password: String, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/di/PreferenceModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/di/PreferenceModule.kt new file mode 100644 index 000000000..f9e4be8a7 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/di/PreferenceModule.kt @@ -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) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt index 4a36c1fc9..295da267d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt @@ -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()), - ) - } -} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt index 13bf31e9e..7e5dc8a3c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt @@ -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( 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)) } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt new file mode 100644 index 000000000..cb65d3180 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 129c1b04b..2fe003c49 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -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() + private val fakeAuthDiskSource = FakeAuthDiskSource() private val mockBitwardenSdk = mockk { 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 +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/base/FakeSharedPreferences.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/FakeSharedPreferences.kt new file mode 100644 index 000000000..8f1148181 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/FakeSharedPreferences.kt @@ -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 = mutableMapOf() + + override fun contains(key: String): Boolean = + sharedPreferences.containsKey(key) + + override fun edit(): SharedPreferences.Editor = Editor() + + override fun getAll(): Map = 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?): Set? = + 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 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?): SharedPreferences.Editor = + putValue(key, value) + + override fun remove(key: String): SharedPreferences.Editor = + apply { pendingSharedPreferences.remove(key) } + + private inline fun putValue( + key: String, + value: T, + ): SharedPreferences.Editor = apply { pendingSharedPreferences[key] = value } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt index 44f557c75..4d8fc0898 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt @@ -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 = "",