diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessNavigation.kt new file mode 100644 index 0000000000..dca5b9ec38 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessNavigation.kt @@ -0,0 +1,56 @@ +package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions + +private const val EMAIL_ADDRESS = "email_address" +private const val NEW_DEVICE_NOTICE_PREFIX = "new_device_notice" +private const val NEW_DEVICE_NOTICE_EMAIL_ACCESS_ROUTE = + "$NEW_DEVICE_NOTICE_PREFIX/{${EMAIL_ADDRESS}}" + +/** + * Class to retrieve new device notice email access arguments from the [SavedStateHandle]. + */ +@OmitFromCoverage +data class NewDeviceNoticeEmailAccessArgs(val emailAddress: String) { + constructor(savedStateHandle: SavedStateHandle) : this( + checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String, + ) +} + +/** + * Navigate to the new device notice email access screen. + */ +fun NavController.navigateToNewDeviceNoticeEmailAccess( + emailAddress: String, + navOptions: NavOptions? = null, +) { + this.navigate( + route = "$NEW_DEVICE_NOTICE_PREFIX/$emailAddress", + navOptions = navOptions, + ) +} + +/** + * Add the new device notice email access screen to the nav graph. + */ +fun NavGraphBuilder.newDeviceNoticeEmailAccessDestination( + onNavigateToTwoFactorOptions: () -> Unit, +) { + composableWithSlideTransitions( + route = NEW_DEVICE_NOTICE_EMAIL_ACCESS_ROUTE, + arguments = listOf( + navArgument(EMAIL_ADDRESS) { type = NavType.StringType }, + ), + ) { + NewDeviceNoticeEmailAccessScreen( + onNavigateToTwoFactorOptions = onNavigateToTwoFactorOptions, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreen.kt new file mode 100644 index 0000000000..de54477a33 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreen.kt @@ -0,0 +1,190 @@ +package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +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.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessAction.ContinueClick +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessAction.EmailAccessToggle +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessEvent.NavigateToTwoFactorOptions +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString +import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * The top level composable for the new device notice email access screen. + */ +@Composable +fun NewDeviceNoticeEmailAccessScreen( + onNavigateToTwoFactorOptions: () -> Unit, + viewModel: NewDeviceNoticeEmailAccessViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + EventsEffect(viewModel = viewModel) { event -> + when (event) { + NavigateToTwoFactorOptions -> onNavigateToTwoFactorOptions() + } + } + + BitwardenScaffold { + NewDeviceNoticeEmailAccessContent( + email = state.email, + isEmailAccessEnabled = state.isEmailAccessEnabled, + onEmailAccessToggleChanged = remember(viewModel) { + { newState -> + viewModel.trySendAction(EmailAccessToggle(isEnabled = newState)) + } + }, + onContinueClick = { viewModel.trySendAction(ContinueClick) }, + ) + } +} + +@Composable +private fun NewDeviceNoticeEmailAccessContent( + email: String, + isEmailAccessEnabled: Boolean, + onEmailAccessToggleChanged: (Boolean) -> Unit, + onContinueClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .standardHorizontalMargin() + .fillMaxSize() + .verticalScroll(state = rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(104.dp)) + HeaderContent() + Spacer(modifier = Modifier.height(24.dp)) + MainContent( + email = email, + isEmailAccessEnabled = isEmailAccessEnabled, + onEmailAccessToggleChanged = onEmailAccessToggleChanged, + ) + Spacer(modifier = Modifier.height(24.dp)) + BitwardenFilledButton( + label = stringResource(R.string.continue_text), + onClick = onContinueClick, + modifier = Modifier + .fillMaxSize() + .imePadding(), + ) + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} + +/** + * Header content containing the warning icon and title. + */ +@Suppress("MaxLineLength") +@Composable +private fun ColumnScope.HeaderContent() { + Image( + painter = rememberVectorPainter(id = R.drawable.warning), + contentDescription = null, + modifier = Modifier.size(120.dp), + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.important_notice), + style = BitwardenTheme.typography.titleMedium, + color = BitwardenTheme.colorScheme.text.primary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource( + R.string.bitwarden_will_soon_send_a_code_to_your_account_email_to_verify_logins_from_new_devices_in_february, + ), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.primary, + textAlign = TextAlign.Center, + ) +} + +/** + * The main content of the screen. + */ +@Composable +private fun MainContent( + email: String, + isEmailAccessEnabled: Boolean, + onEmailAccessToggleChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + Text( + text = createAnnotatedString( + mainString = stringResource( + R.string.do_you_have_reliable_access_to_your_email, + email, + ), + mainStringStyle = SpanStyle( + color = BitwardenTheme.colorScheme.text.primary, + fontSize = BitwardenTheme.typography.bodyLarge.fontSize, + fontWeight = FontWeight.Normal, + ), + highlights = listOf(email), + highlightStyle = SpanStyle( + color = BitwardenTheme.colorScheme.text.primary, + fontSize = BitwardenTheme.typography.bodyLarge.fontSize, + fontWeight = FontWeight.Bold, + ), + ), + ) + Column { + BitwardenSwitch( + label = stringResource(id = R.string.yes_i_can_reliably_access_my_email), + isChecked = isEmailAccessEnabled, + onCheckedChange = onEmailAccessToggleChanged, + modifier = Modifier + .testTag("EmailAccessToggle"), + ) + } + } +} + +@PreviewScreenSizes +@Composable +private fun NewDeviceNoticeEmailAccessScreen_preview() { + BitwardenTheme { + NewDeviceNoticeEmailAccessContent( + email = "test@bitwarden.com", + isEmailAccessEnabled = true, + onEmailAccessToggleChanged = {}, + onContinueClick = {}, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModel.kt new file mode 100644 index 0000000000..32a20f9cbc --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModel.kt @@ -0,0 +1,84 @@ +package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessAction.ContinueClick +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessAction.EmailAccessToggle +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessEvent.NavigateToTwoFactorOptions +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * Manages application state for the new device notice email access screen. + */ +@HiltViewModel +class NewDeviceNoticeEmailAccessViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : BaseViewModel< + NewDeviceNoticeEmailAccessState, + NewDeviceNoticeEmailAccessEvent, + NewDeviceNoticeEmailAccessAction, + >( + initialState = savedStateHandle[KEY_STATE] + ?: NewDeviceNoticeEmailAccessState( + email = NewDeviceNoticeEmailAccessArgs(savedStateHandle).emailAddress, + isEmailAccessEnabled = false, + ), +) { + override fun handleAction(action: NewDeviceNoticeEmailAccessAction) { + when (action) { + ContinueClick -> handleContinueClick() + is EmailAccessToggle -> handleEmailAccessToggle(action) + } + } + + private fun handleContinueClick() { + // TODO PM-8217: update new device notice status and navigate accordingly + sendEvent(NavigateToTwoFactorOptions) + } + + private fun handleEmailAccessToggle(action: EmailAccessToggle) { + mutableStateFlow.update { + it.copy(isEmailAccessEnabled = action.isEnabled) + } + } +} + +/** + * Models state of the new device notice email access screen. + */ +@Parcelize +data class NewDeviceNoticeEmailAccessState( + val email: String, + val isEmailAccessEnabled: Boolean, +) : Parcelable + +/** + * Models events for the new device notice email access screen. + */ +sealed class NewDeviceNoticeEmailAccessEvent { + /** + * Navigates to the Two Factor Options screen. + */ + data object NavigateToTwoFactorOptions : NewDeviceNoticeEmailAccessEvent() +} + +/** + * Models actions for the new device notice email access screen. + */ +sealed class NewDeviceNoticeEmailAccessAction { + /** + * User tapped the continue button. + */ + data object ContinueClick : NewDeviceNoticeEmailAccessAction() + + /** + * User tapped the email access toggle. + */ + data class EmailAccessToggle(val isEnabled: Boolean) : NewDeviceNoticeEmailAccessAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt index 4efcade16a..983919a0da 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/Text.kt @@ -130,6 +130,8 @@ fun @receiver:StringRes Int.asText(vararg args: Any): Text = ResArgsText(this, a * Create an [AnnotatedString] with highlighted parts. * @param mainString the full string * @param highlights parts of the mainString that will be highlighted + * @param highlightStyle the style to apply to the highlights + * @param mainStringStyle the style to apply to the mainString * @param tag the tag that will be used for the annotation */ @Composable @@ -137,12 +139,13 @@ fun createAnnotatedString( mainString: String, highlights: List, highlightStyle: SpanStyle = bitwardenClickableTextSpanStyle, + mainStringStyle: SpanStyle = bitwardenDefaultSpanStyle, tag: String? = null, ): AnnotatedString { return buildAnnotatedString { append(mainString) addStyle( - style = bitwardenDefaultSpanStyle, + style = mainStringStyle, start = 0, end = mainString.length, ) diff --git a/app/src/main/res/drawable-night/warning.xml b/app/src/main/res/drawable-night/warning.xml new file mode 100644 index 0000000000..f173ccbf1e --- /dev/null +++ b/app/src/main/res/drawable-night/warning.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/warning.xml b/app/src/main/res/drawable/warning.xml new file mode 100644 index 0000000000..55bbf1f486 --- /dev/null +++ b/app/src/main/res/drawable/warning.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 29f21f40e9..d312471c4b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1098,6 +1098,10 @@ Do you want to switch to this account? Copy email Copy phone number Copy address + Important notice + Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025. + Do you have reliable access to your email, %1$s? + Yes, I can reliably access my email Biometrics are no longer supported on this device You’ve been logged out because your device’s biometrics don’t meet the latest security requirements. To update settings, log in once again or contact your administrator for access. CXP Import diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreenTest.kt new file mode 100644 index 0000000000..2b6e123327 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreenTest.kt @@ -0,0 +1,85 @@ +package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice + +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test + +class NewDeviceNoticeEmailAccessScreenTest : BaseComposeTest() { + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + private var onNavigateToTwoFactorOptionsCalled = false + private val viewModel = mockk(relaxed = true) { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + } + + @Before + fun setUp() { + composeTestRule.setContent { + NewDeviceNoticeEmailAccessScreen( + onNavigateToTwoFactorOptions = { onNavigateToTwoFactorOptionsCalled = true }, + viewModel = viewModel, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `Do you have reliable access to your email should be toggled on or off according to the state`() { + composeTestRule + .onNodeWithText("Yes, I can reliably access my email", substring = true) + .assertIsOff() + + mutableStateFlow.update { it.copy(isEmailAccessEnabled = true) } + + composeTestRule + .onNodeWithText("Yes, I can reliably access my email", substring = true) + .assertIsOn() + } + + @Test + fun `Do you have reliable access to your email click should send EmailAccessToggle action`() { + composeTestRule + .onNodeWithText("Yes, I can reliably access my email") + .performClick() + verify { + viewModel.trySendAction( + NewDeviceNoticeEmailAccessAction.EmailAccessToggle(true), + ) + } + } + + @Test + fun `Continue button click should send ContinueButtonClick action`() { + composeTestRule.onNodeWithText("Continue").performScrollTo().performClick() + verify { + viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.ContinueClick) + } + } + + @Test + fun `ContinueClick should call onNavigateToTwoFactorOptions`() { + mutableEventFlow.tryEmit(NewDeviceNoticeEmailAccessEvent.NavigateToTwoFactorOptions) + assertTrue(onNavigateToTwoFactorOptionsCalled) + } +} + +private const val EMAIL = "active@bitwarden.com" + +private val DEFAULT_STATE = + NewDeviceNoticeEmailAccessState( + email = EMAIL, + isEmailAccessEnabled = false, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModelTest.kt new file mode 100644 index 0000000000..5761b90606 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModelTest.kt @@ -0,0 +1,59 @@ +package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class NewDeviceNoticeEmailAccessViewModelTest : BaseViewModelTest() { + + @Test + fun `initial state should be correct with email from state handle`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + } + } + + @Test + fun `EmailAccessToggle should update value of isEmailAccessEnabled`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.EmailAccessToggle(true)) + assertEquals( + viewModel.stateFlow.value, + DEFAULT_STATE.copy(isEmailAccessEnabled = true), + ) + } + } + + @Test + fun `ContinueClick with valid email should emit NavigateToTwoFactorOptions`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.ContinueClick) + assertEquals( + NewDeviceNoticeEmailAccessEvent.NavigateToTwoFactorOptions, + awaitItem(), + ) + } + } + + private fun createViewModel( + savedStateHandle: SavedStateHandle = SavedStateHandle().also { + it["email_address"] = EMAIL + }, + ): NewDeviceNoticeEmailAccessViewModel = NewDeviceNoticeEmailAccessViewModel( + savedStateHandle = savedStateHandle, + ) +} + +private const val EMAIL = "active@bitwarden.com" + +private val DEFAULT_STATE = + NewDeviceNoticeEmailAccessState( + email = EMAIL, + isEmailAccessEnabled = false, + )