BITAU-90 Add "Sync with Bitwarden App" row to settings (#239)

This commit is contained in:
Andrew Haisting
2024-10-15 15:38:53 -05:00
committed by GitHub
parent 1820073b65
commit d07a9dcf81
8 changed files with 466 additions and 5 deletions

View File

@@ -102,6 +102,12 @@ android {
lint {
disable.add("MissingTranslation")
}
@Suppress("UnstableApiUsage")
testOptions {
// Required for Robolectric
unitTests.isIncludeAndroidResources = true
unitTests.isReturnDefaultValues = true
}
}
kotlin {

View File

@@ -1,5 +1,6 @@
package com.bitwarden.authenticator.ui.platform.feature.settings
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -102,6 +103,24 @@ fun SettingsScreen(
SettingsEvent.NavigateToPrivacyPolicy -> {
intentManager.launchUri("https://bitwarden.com/privacy".toUri())
}
SettingsEvent.NavigateToBitwardenApp -> {
intentManager.startActivity(
Intent(
Intent.ACTION_VIEW,
"bitwarden://settings/account_security".toUri(),
).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
SettingsEvent.NavigateToBitwardenPlayStoreListing -> {
intentManager.launchUri(
"https://play.google.com/store/apps/details?id=com.x8bit.bitwarden".toUri(),
)
}
}
}
@@ -150,6 +169,12 @@ fun SettingsScreen(
viewModel.trySendAction(SettingsAction.DataClick.BackupClick)
}
},
onSyncWithBitwardenClick = remember(viewModel) {
{
viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick)
}
},
shouldShowSyncWithBitwardenApp = state.showSyncWithBitwarden,
)
Spacer(modifier = Modifier.height(16.dp))
AppearanceSettings(
@@ -237,11 +262,14 @@ private fun SecuritySettings(
//region Data settings
@Composable
@Suppress("LongMethod")
private fun VaultSettings(
modifier: Modifier = Modifier,
onExportClick: () -> Unit,
onImportClick: () -> Unit,
onBackupClick: () -> Unit,
onSyncWithBitwardenClick: () -> Unit,
shouldShowSyncWithBitwardenApp: Boolean,
) {
BitwardenListHeaderText(
modifier = Modifier.padding(horizontal = 16.dp),
@@ -294,6 +322,25 @@ private fun VaultSettings(
dialogConfirmButtonText = stringResource(R.string.learn_more),
dialogDismissButtonText = stringResource(R.string.ok),
)
if (shouldShowSyncWithBitwardenApp) {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextRow(
text = stringResource(id = R.string.sync_with_bitwarden_app),
onClick = onSyncWithBitwardenClick,
modifier = modifier,
withDivider = true,
content = {
Icon(
modifier = Modifier
.mirrorIfRtl()
.size(24.dp),
painter = painterResource(id = R.drawable.ic_external_link),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
},
)
}
}
@Composable

View File

@@ -7,7 +7,9 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.authenticator.BuildConfig
import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager
import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager
import com.bitwarden.authenticator.data.platform.manager.model.LocalFeatureFlag
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult
import com.bitwarden.authenticator.ui.platform.base.BaseViewModel
@@ -16,6 +18,8 @@ import com.bitwarden.authenticator.ui.platform.base.util.asText
import com.bitwarden.authenticator.ui.platform.base.util.concat
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -34,16 +38,21 @@ private const val KEY_STATE = "state"
class SettingsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
clock: Clock,
private val authenticatorBridgeManager: AuthenticatorBridgeManager,
private val settingsRepository: SettingsRepository,
private val clipboardManager: BitwardenClipboardManager,
featureFlagManager: FeatureFlagManager,
) : BaseViewModel<SettingsState, SettingsEvent, SettingsAction>(
initialState = savedStateHandle[KEY_STATE]
?: createInitialState(
clock,
settingsRepository.appLanguage,
settingsRepository.appTheme,
settingsRepository.isUnlockWithBiometricsEnabled,
settingsRepository.isCrashLoggingEnabled,
clock = clock,
appLanguage = settingsRepository.appLanguage,
appTheme = settingsRepository.appTheme,
unlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled,
isSubmitCrashLogsEnabled = settingsRepository.isCrashLoggingEnabled,
isSyncWithBitwardenFeatureEnabled =
featureFlagManager.getFeatureFlag(LocalFeatureFlag.PasswordManagerSync),
accountSyncState = authenticatorBridgeManager.accountSyncStateFlow.value,
),
) {
override fun handleAction(action: SettingsAction) {
@@ -131,6 +140,17 @@ class SettingsViewModel @Inject constructor(
SettingsAction.DataClick.ExportClick -> handleExportClick()
SettingsAction.DataClick.ImportClick -> handleImportClick()
SettingsAction.DataClick.BackupClick -> handleBackupClick()
SettingsAction.DataClick.SyncWithBitwardenClick -> handleSyncWithBitwardenClick()
}
}
private fun handleSyncWithBitwardenClick() {
when (authenticatorBridgeManager.accountSyncStateFlow.value) {
AccountSyncState.AppNotInstalled -> {
sendEvent(SettingsEvent.NavigateToBitwardenPlayStoreListing)
}
else -> sendEvent(SettingsEvent.NavigateToBitwardenApp)
}
}
@@ -228,15 +248,21 @@ class SettingsViewModel @Inject constructor(
@Suppress("UndocumentedPublicClass")
companion object {
@Suppress("LongParameterList")
private fun createInitialState(
clock: Clock,
appLanguage: AppLanguage,
appTheme: AppTheme,
unlockWithBiometricsEnabled: Boolean,
isSubmitCrashLogsEnabled: Boolean,
accountSyncState: AccountSyncState,
isSyncWithBitwardenFeatureEnabled: Boolean,
): SettingsState {
val currentYear = Year.now(clock)
val copyrightInfo = "© Bitwarden Inc. 2015-$currentYear".asText()
// Show sync with Bitwarden row if feature is enabled and the OS is supported:
val shouldShowSyncWithBitwarden = isSyncWithBitwardenFeatureEnabled &&
accountSyncState != AccountSyncState.OsVersionNotSupported
return SettingsState(
appearance = SettingsState.Appearance(
language = appLanguage,
@@ -249,6 +275,7 @@ class SettingsViewModel @Inject constructor(
.asText()
.concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()),
copyrightInfo = copyrightInfo,
showSyncWithBitwarden = shouldShowSyncWithBitwarden,
)
}
}
@@ -262,6 +289,7 @@ data class SettingsState(
val appearance: Appearance,
val isUnlockWithBiometricsEnabled: Boolean,
val isSubmitCrashLogsEnabled: Boolean,
val showSyncWithBitwarden: Boolean,
val dialog: Dialog?,
val version: Text,
val copyrightInfo: Text,
@@ -325,6 +353,16 @@ sealed class SettingsEvent {
* Navigate to the privacy policy web page.
*/
data object NavigateToPrivacyPolicy : SettingsEvent()
/**
* Navigate to the Bitwarden account settings.
*/
data object NavigateToBitwardenApp : SettingsEvent()
/**
* Navigate to the Bitwarden Play Store listing.
*/
data object NavigateToBitwardenPlayStoreListing : SettingsEvent()
}
/**
@@ -376,6 +414,11 @@ sealed class SettingsAction(
* Indicates the user click backup.
*/
data object BackupClick : DataClick()
/**
* Indicates the user clicked sync with Bitwarden.
*/
data object SyncWithBitwardenClick : DataClick()
}
/**

View File

@@ -124,4 +124,5 @@
<string name="download_bitwarden_card_title">Download the Bitwarden app</string>
<string name="download_bitwarden_card_message">Store all of your logins and sync verification codes directly with the Authenticator app.</string>
<string name="download">Download</string>
<string name="sync_with_bitwarden_app">Sync with Bitwarden app</string>
</resources>

View File

@@ -0,0 +1,59 @@
package com.bitwarden.authenticator.ui.platform.base
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.createComposeRule
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme
import org.junit.Rule
/**
* A base class that can be used for performing Compose-layer testing using Robolectric, Compose
* Testing, and JUnit 4.
*/
abstract class BaseComposeTest : BaseRobolectricTest() {
@get:Rule
val composeTestRule = createComposeRule()
/**
* instance of [OnBackPressedDispatcher] made available if testing using
*
* [setContentWithBackDispatcher] or [runTestWithTheme]
*/
var backDispatcher: OnBackPressedDispatcher? = null
private set
/**
* Helper for testing a basic Composable function that only requires a Composable environment
* with the [BitwardenTheme].
*/
protected fun runTestWithTheme(
theme: AppTheme,
test: @Composable () -> Unit,
) {
composeTestRule.setContent {
AuthenticatorTheme(
theme = theme,
) {
backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
test()
}
}
}
/**
* Helper for testing a basic Composable function that provides access to a
* [OnBackPressedDispatcher].
*
* Use if the [Composable] function being tested uses a [BackHandler]
*/
protected fun setContentWithBackDispatcher(test: @Composable () -> Unit) {
composeTestRule.setContent {
backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
test()
}
}
}

View File

@@ -0,0 +1,21 @@
package com.bitwarden.authenticator.ui.platform.base
import dagger.hilt.android.testing.HiltTestApplication
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowLog
/**
* A base class that can be used for performing tests that use Robolectric and JUnit 4.
*/
@Config(
application = HiltTestApplication::class,
sdk = [Config.NEWEST_SDK],
)
@RunWith(RobolectricTestRunner::class)
abstract class BaseRobolectricTest {
init {
ShadowLog.stream = System.out
}
}

View File

@@ -0,0 +1,128 @@
package com.bitwarden.authenticator.ui.platform.feature.settings
import android.content.Intent
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.core.net.toUri
import com.bitwarden.authenticator.BuildConfig
import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest
import com.bitwarden.authenticator.ui.platform.base.util.asText
import com.bitwarden.authenticator.ui.platform.base.util.concat
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager
import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
import org.junit.Test
import org.junit.Before
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
class SettingsScreenTest : BaseComposeTest() {
private var onNavigateToTutorialCalled = false
private var onNaviateToExportCalled = false
private var onNavigateToImportCalled = false
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<SettingsEvent>()
val viewModel: SettingsViewModel = mockk {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
every { trySendAction(any()) } just runs
}
private val biometricsManager: BiometricsManager = mockk {
every { isBiometricsSupported } returns true
}
private val intentManager: IntentManager = mockk()
@Before
fun setup() {
composeTestRule.setContent {
SettingsScreen(
viewModel = viewModel,
biometricsManager = biometricsManager,
intentManager = intentManager,
onNavigateToTutorial = { onNavigateToTutorialCalled = true },
onNavigateToExport = { onNaviateToExportCalled = true },
onNavigateToImport = { onNavigateToImportCalled = true },
)
}
}
@Test
fun `Sync with Bitwarden row should be hidden when showSyncWithBitwarden is false`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
showSyncWithBitwarden = false,
)
composeTestRule.onNodeWithText("Sync with Bitwarden app").assertDoesNotExist()
}
@Test
fun `Sync with Bitwarden row click should send SyncWithBitwardenClick action`() {
composeTestRule
.onNodeWithText("Sync with Bitwarden app")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick) }
}
@Test
fun `on NavigateToBitwardenApp receive should launch bitwarden account security deep link`() {
every { intentManager.startActivity(any()) } just runs
val intentSlot = slot<Intent>()
val expectedIntent = Intent(
Intent.ACTION_VIEW,
"bitwarden://settings/account_security".toUri(),
).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
mutableEventFlow.tryEmit(SettingsEvent.NavigateToBitwardenApp)
verify { intentManager.startActivity(capture(intentSlot)) }
assertEquals(
expectedIntent.data,
intentSlot.captured.data,
)
assertEquals(
expectedIntent.flags,
intentSlot.captured.flags,
)
}
@Test
fun `on NavigateToBitwardenPlayStoreListing receive launch Bitwarden Play Store URI`() {
every { intentManager.launchUri(any()) } just runs
mutableEventFlow.tryEmit(SettingsEvent.NavigateToBitwardenPlayStoreListing)
verify {
intentManager.launchUri(
"https://play.google.com/store/apps/details?id=com.x8bit.bitwarden".toUri(),
)
}
}
}
private val APP_LANGUAGE = AppLanguage.ENGLISH
private val APP_THEME = AppTheme.DEFAULT
private val DEFAULT_STATE = SettingsState(
appearance = SettingsState.Appearance(
APP_LANGUAGE,
APP_THEME,
),
isSubmitCrashLogsEnabled = true,
isUnlockWithBiometricsEnabled = true,
showSyncWithBitwarden = true,
dialog = null,
version = R.string.version.asText()
.concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()),
copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(),
)

View File

@@ -0,0 +1,156 @@
package com.bitwarden.authenticator.ui.platform.feature.settings
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.authenticator.BuildConfig
import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager
import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager
import com.bitwarden.authenticator.data.platform.manager.model.LocalFeatureFlag
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest
import com.bitwarden.authenticator.ui.platform.base.util.asText
import com.bitwarden.authenticator.ui.platform.base.util.concat
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
import io.mockk.every
import io.mockk.mockk
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 SettingsViewModelTest : BaseViewModelTest() {
private val authenticatorBridgeManager: AuthenticatorBridgeManager = mockk {
every { accountSyncStateFlow } returns MutableStateFlow(AccountSyncState.Loading)
}
private val settingsRepository: SettingsRepository = mockk {
every { appLanguage } returns APP_LANGUAGE
every { appTheme } returns APP_THEME
every { isUnlockWithBiometricsEnabled } returns true
every { isCrashLoggingEnabled } returns true
}
private val clipboardManager: BitwardenClipboardManager = mockk()
private val featureFlagManager: FeatureFlagManager = mockk {
every { getFeatureFlag(LocalFeatureFlag.PasswordManagerSync) } returns true
}
@Test
@Suppress("MaxLineLength")
fun `initialState should be correct when saved state is null and password manager feature flag is off`() {
every {
featureFlagManager.getFeatureFlag(LocalFeatureFlag.PasswordManagerSync)
} returns false
val viewModel = createViewModel(savedState = null)
val expectedState = DEFAULT_STATE.copy(
showSyncWithBitwarden = false,
)
assertEquals(
expectedState,
viewModel.stateFlow.value,
)
}
@Test
@Suppress("MaxLineLength")
fun `initialState should be correct when saved state is null and password manager feature flag is on but OS version is too low`() {
every {
authenticatorBridgeManager.accountSyncStateFlow
} returns MutableStateFlow(AccountSyncState.OsVersionNotSupported)
val viewModel = createViewModel(savedState = null)
val expectedState = DEFAULT_STATE.copy(
showSyncWithBitwarden = false,
)
assertEquals(
expectedState,
viewModel.stateFlow.value,
)
}
@Test
@Suppress("MaxLineLength")
fun `initialState should be correct when saved state is null and password manager feature flag is on and OS version is supported`() {
every {
authenticatorBridgeManager.accountSyncStateFlow
} returns MutableStateFlow(AccountSyncState.Loading)
every {
featureFlagManager.getFeatureFlag(LocalFeatureFlag.PasswordManagerSync)
} returns true
val viewModel = createViewModel(savedState = null)
val expectedState = DEFAULT_STATE.copy(
showSyncWithBitwarden = true,
)
assertEquals(
expectedState,
viewModel.stateFlow.value,
)
}
@Test
@Suppress("MaxLineLength")
fun `on SyncWithBitwardenClick receive with AccountSyncState AppNotInstalled should emit NavigateToBitwardenPlayStoreListing`() =
runTest {
every {
authenticatorBridgeManager.accountSyncStateFlow
} returns MutableStateFlow(AccountSyncState.AppNotInstalled)
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick)
assertEquals(
SettingsEvent.NavigateToBitwardenPlayStoreListing,
awaitItem(),
)
}
}
@Test
@Suppress("MaxLineLength")
fun `on SyncWithBitwardenClick receive with AccountSyncState not AppNotInstalled should emit NavigateToBitwardenApp`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick)
assertEquals(
SettingsEvent.NavigateToBitwardenApp,
awaitItem(),
)
}
}
private fun createViewModel(
savedState: SettingsState? = DEFAULT_STATE,
) = SettingsViewModel(
savedStateHandle = SavedStateHandle().apply { this["state"] = savedState },
clock = CLOCK,
authenticatorBridgeManager = authenticatorBridgeManager,
settingsRepository = settingsRepository,
clipboardManager = clipboardManager,
featureFlagManager = featureFlagManager,
)
}
private val APP_LANGUAGE = AppLanguage.ENGLISH
private val APP_THEME = AppTheme.DEFAULT
private val CLOCK = Clock.fixed(
Instant.parse("2024-10-12T12:00:00Z"),
ZoneOffset.UTC,
)
private val DEFAULT_STATE = SettingsState(
appearance = SettingsState.Appearance(
APP_LANGUAGE,
APP_THEME,
),
isSubmitCrashLogsEnabled = true,
isUnlockWithBiometricsEnabled = true,
showSyncWithBitwarden = true,
dialog = null,
version = R.string.version.asText()
.concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()),
copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(),
)