diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/row/BitwardenPushRow.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/row/BitwardenPushRow.kt new file mode 100644 index 0000000000..419ccf5053 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/row/BitwardenPushRow.kt @@ -0,0 +1,139 @@ +package com.x8bit.bitwarden.ui.platform.components.row + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.cardStyle +import com.x8bit.bitwarden.ui.platform.base.util.mirrorIfRtl +import com.x8bit.bitwarden.ui.platform.components.badge.NotificationBadge +import com.x8bit.bitwarden.ui.platform.components.icon.BitwardenIcon +import com.x8bit.bitwarden.ui.platform.components.model.CardStyle +import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * Reusable row with push icon built in. + * + * @param text The displayable text. + * @param onClick The callback when the row is clicked. + * @param cardStyle The [CardStyle] to be applied to this row. + * @param modifier The modifier for this composable. + * @param leadingIcon An optional leading icon. + * @param notificationCount The optional notification count to be displayed. + */ +@Composable +fun BitwardenPushRow( + text: String, + onClick: () -> Unit, + cardStyle: CardStyle, + modifier: Modifier = Modifier, + leadingIcon: IconData? = null, + notificationCount: Int = 0, +) { + Row( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 60.dp) + .cardStyle( + cardStyle = cardStyle, + onClick = onClick, + paddingStart = leadingIcon?.let { 12.dp } ?: 16.dp, + paddingEnd = 20.dp, + paddingTop = 6.dp, + paddingBottom = 6.dp, + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .defaultMinSize(minHeight = 48.dp) + .weight(weight = 1f), + ) { + leadingIcon?.let { + BitwardenIcon( + iconData = it, + tint = BitwardenTheme.colorScheme.icon.primary, + modifier = Modifier.size(size = 24.dp), + ) + Spacer(modifier = Modifier.width(width = 12.dp)) + } + Text( + text = text, + style = BitwardenTheme.typography.bodyLarge, + color = BitwardenTheme.colorScheme.text.primary, + modifier = Modifier.fillMaxWidth(), + ) + } + TrailingContent(notificationCount = notificationCount) + } +} + +@Composable +private fun TrailingContent( + notificationCount: Int, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.defaultMinSize(minHeight = 48.dp), + ) { + val notificationBadgeVisible = notificationCount > 0 + NotificationBadge( + notificationCount = notificationCount, + isVisible = notificationBadgeVisible, + ) + if (notificationBadgeVisible) { + Spacer(modifier = Modifier.width(12.dp)) + } + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_chevron_right), + contentDescription = null, + tint = BitwardenTheme.colorScheme.icon.primary, + modifier = Modifier + .mirrorIfRtl() + .size(size = 16.dp), + ) + } +} + +@Preview +@Composable +private fun BitwardenPushRow_preview() { + BitwardenTheme { + Column { + BitwardenPushRow( + text = "Plain Row", + onClick = { }, + cardStyle = CardStyle.Top(), + ) + BitwardenPushRow( + text = "Icon Row", + onClick = { }, + cardStyle = CardStyle.Middle(), + leadingIcon = IconData.Local(iconRes = R.drawable.ic_vault), + ) + BitwardenPushRow( + text = "Notification Row", + onClick = { }, + cardStyle = CardStyle.Bottom, + notificationCount = 3, + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/toggle/BitwardenSwitch.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/toggle/BitwardenSwitch.kt index 06c3e567ba..deee31a199 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/toggle/BitwardenSwitch.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/toggle/BitwardenSwitch.kt @@ -254,7 +254,7 @@ fun BitwardenSwitch( cardStyle = cardStyle, onClick = onCheckedChange?.let { { it(!isChecked) } }, clickEnabled = !readOnly && enabled, - paddingTop = 12.dp, + paddingTop = 6.dp, paddingBottom = 0.dp, ) .semantics(mergeDescendants = true) { @@ -264,7 +264,7 @@ fun BitwardenSwitch( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.defaultMinSize(minHeight = 36.dp), + modifier = Modifier.defaultMinSize(minHeight = 48.dp), ) { Spacer(modifier = Modifier.width(width = 16.dp)) Row( @@ -329,7 +329,7 @@ fun BitwardenSwitch( content = content, ) } - ?: Spacer(modifier = Modifier.height(height = cardStyle?.let { 12.dp } ?: 0.dp)) + ?: Spacer(modifier = Modifier.height(height = cardStyle?.let { 6.dp } ?: 0.dp)) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt index 370a6a19b4..82ebaa8e6e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt @@ -36,6 +36,8 @@ fun NavGraphBuilder.settingsGraph( onNavigateToPendingRequests: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit, + onNavigateToFlightRecorder: () -> Unit, + onNavigateToRecordedLogs: () -> Unit, onNavigateToImportLogins: (SnackbarRelay) -> Unit, ) { navigation( @@ -54,7 +56,11 @@ fun NavGraphBuilder.settingsGraph( onNavigateToVault = { navController.navigateToVaultSettings() }, ) } - aboutDestination(onNavigateBack = { navController.popBackStack() }) + aboutDestination( + onNavigateBack = { navController.popBackStack() }, + onNavigateToFlightRecorder = onNavigateToFlightRecorder, + onNavigateToRecordedLogs = onNavigateToRecordedLogs, + ) accountSecurityDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToDeleteAccount = onNavigateToDeleteAccount, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt index 20b470a60f..a587b110f2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt @@ -1,51 +1,34 @@ package com.x8bit.bitwarden.ui.platform.feature.settings -import androidx.annotation.DrawableRes -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState 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.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview 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.platform.base.util.EventsEffect -import com.x8bit.bitwarden.ui.platform.base.util.Text -import com.x8bit.bitwarden.ui.platform.base.util.cardStyle -import com.x8bit.bitwarden.ui.platform.base.util.mirrorIfRtl import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.base.util.toListItemCardStyle import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenMediumTopAppBar -import com.x8bit.bitwarden.ui.platform.components.badge.NotificationBadge -import com.x8bit.bitwarden.ui.platform.components.model.CardStyle +import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.platform.components.row.BitwardenPushRow import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold -import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter -import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme /** * Displays the settings screen. @@ -91,8 +74,8 @@ fun SettingsScreen( ) { Spacer(modifier = Modifier.height(height = 12.dp)) Settings.entries.forEachIndexed { index, settingEntry -> - SettingsRow( - text = settingEntry.text, + BitwardenPushRow( + text = settingEntry.text(), onClick = remember(viewModel) { { viewModel.trySendAction(SettingsAction.SettingsClick(settingEntry)) } }, @@ -105,7 +88,7 @@ fun SettingsScreen( // Start padding, plus icon, plus spacing between text. dividerPadding = 48.dp, ), - iconVectorResource = settingEntry.vectorIconRes, + leadingIcon = IconData.Local(iconRes = settingEntry.vectorIconRes), modifier = Modifier .testTag(tag = settingEntry.testTag) .standardHorizontalMargin() @@ -117,102 +100,3 @@ fun SettingsScreen( } } } - -@Composable -private fun SettingsRow( - text: Text, - onClick: () -> Unit, - notificationCount: Int, - cardStyle: CardStyle?, - @DrawableRes iconVectorResource: Int, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 60.dp) - .cardStyle( - cardStyle = cardStyle, - onClick = onClick, - paddingStart = 12.dp, - paddingEnd = 12.dp, - ), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painter = rememberVectorPainter(iconVectorResource), - contentDescription = null, - tint = BitwardenTheme.colorScheme.icon.primary, - modifier = Modifier.size(24.dp), - ) - Spacer(Modifier.width(12.dp)) - Text( - modifier = Modifier - .padding(end = 16.dp), - text = text(), - style = BitwardenTheme.typography.bodyLarge, - color = BitwardenTheme.colorScheme.text.primary, - ) - } - TrailingContent(notificationCount = notificationCount) - } -} - -@Composable -private fun TrailingContent( - notificationCount: Int, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - val notificationBadgeVisible = notificationCount > 0 - NotificationBadge( - notificationCount = notificationCount, - isVisible = notificationBadgeVisible, - ) - if (notificationBadgeVisible) { - Spacer(modifier = Modifier.width(12.dp)) - } - Icon( - painter = rememberVectorPainter(id = R.drawable.ic_chevron_right), - contentDescription = null, - tint = BitwardenTheme.colorScheme.icon.primary, - modifier = Modifier - .mirrorIfRtl() - .size(size = 16.dp), - ) - } -} - -@Preview -@Preview(name = "Right-To-Left", locale = "ar") -@Composable -private fun SettingsRows_preview() { - BitwardenTheme { - Column( - modifier = Modifier - .background(BitwardenTheme.colorScheme.background.primary) - .padding(16.dp) - .fillMaxSize(), - ) { - Settings.entries.forEachIndexed { index, it -> - SettingsRow( - text = it.text, - onClick = { }, - notificationCount = index % 3, - iconVectorResource = it.vectorIconRes, - cardStyle = Settings.entries.toListItemCardStyle( - index = index, - dividerPadding = 48.dp, - ), - ) - } - } - } -} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutNavigation.kt index 8c47693036..8874db9b68 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutNavigation.kt @@ -12,11 +12,17 @@ private const val ABOUT_ROUTE = "settings_about" */ fun NavGraphBuilder.aboutDestination( onNavigateBack: () -> Unit, + onNavigateToFlightRecorder: () -> Unit, + onNavigateToRecordedLogs: () -> Unit, ) { composableWithPushTransitions( route = ABOUT_ROUTE, ) { - AboutScreen(onNavigateBack = onNavigateBack) + AboutScreen( + onNavigateBack = onNavigateBack, + onNavigateToFlightRecorder = onNavigateToFlightRecorder, + onNavigateToRecordedLogs = onNavigateToRecordedLogs, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreen.kt index 14e912e860..8eb1ecfa19 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreen.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.about import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize @@ -37,7 +38,9 @@ import com.x8bit.bitwarden.ui.platform.base.util.mirrorIfRtl import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.model.CardStyle +import com.x8bit.bitwarden.ui.platform.components.model.TooltipData import com.x8bit.bitwarden.ui.platform.components.row.BitwardenExternalLinkRow +import com.x8bit.bitwarden.ui.platform.components.row.BitwardenPushRow import com.x8bit.bitwarden.ui.platform.components.row.BitwardenTextRow import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch @@ -54,17 +57,23 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme @Composable fun AboutScreen( onNavigateBack: () -> Unit, + onNavigateToFlightRecorder: () -> Unit, + onNavigateToRecordedLogs: () -> Unit, viewModel: AboutViewModel = hiltViewModel(), intentManager: IntentManager = LocalIntentManager.current, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() EventsEffect(viewModel = viewModel) { event -> when (event) { - is AboutEvent.NavigateToWebVault -> { - intentManager.launchUri(event.vaultUrl.toUri()) - } - + is AboutEvent.NavigateToWebVault -> intentManager.launchUri(event.vaultUrl.toUri()) AboutEvent.NavigateBack -> onNavigateBack.invoke() + AboutEvent.NavigateToFlightRecorder -> onNavigateToFlightRecorder() + AboutEvent.NavigateToRecordedLogs -> onNavigateToRecordedLogs() + + AboutEvent.NavigateToFlightRecorderHelp -> { + // TODO: PM-19809 Update this URL to be specific to the flight recorder + intentManager.launchUri("https://bitwarden.com/help".toUri()) + } AboutEvent.NavigateToHelpCenter -> { intentManager.launchUri("https://bitwarden.com/help".toUri()) @@ -97,7 +106,7 @@ fun AboutScreen( ) }, ) { - ContentColumn( + AboutScreenContent( state = state, modifier = Modifier.fillMaxSize(), onHelpCenterClick = remember(viewModel) { @@ -112,6 +121,15 @@ fun AboutScreen( onSubmitCrashLogsCheckedChange = remember(viewModel) { { viewModel.trySendAction(AboutAction.SubmitCrashLogsClick(it)) } }, + onFlightRecorderCheckedChange = remember(viewModel) { + { viewModel.trySendAction(AboutAction.FlightRecorderCheckedChange(it)) } + }, + onFlightRecorderTooltipClick = remember(viewModel) { + { viewModel.trySendAction(AboutAction.FlightRecorderTooltipClick) } + }, + onViewRecordedLogsClick = remember(viewModel) { + { viewModel.trySendAction(AboutAction.ViewRecordedLogsClick) } + }, onVersionClick = remember(viewModel) { { viewModel.trySendAction(AboutAction.VersionClick) } }, @@ -124,12 +142,15 @@ fun AboutScreen( @Suppress("LongMethod") @Composable -private fun ContentColumn( +private fun AboutScreenContent( state: AboutState, onHelpCenterClick: () -> Unit, onPrivacyPolicyClick: () -> Unit, onLearnAboutOrgsClick: () -> Unit, onSubmitCrashLogsCheckedChange: (Boolean) -> Unit, + onFlightRecorderCheckedChange: (Boolean) -> Unit, + onFlightRecorderTooltipClick: () -> Unit, + onViewRecordedLogsClick: () -> Unit, onVersionClick: () -> Unit, onWebVaultClick: () -> Unit, modifier: Modifier = Modifier, @@ -139,19 +160,18 @@ private fun ContentColumn( .verticalScroll(rememberScrollState()), ) { Spacer(modifier = Modifier.height(height = 12.dp)) - if (state.shouldShowCrashLogsButton) { - BitwardenSwitch( - label = stringResource(id = R.string.submit_crash_logs), - contentDescription = stringResource(id = R.string.submit_crash_logs), - isChecked = state.isSubmitCrashLogsEnabled, - onCheckedChange = onSubmitCrashLogsCheckedChange, - cardStyle = CardStyle.Top(), - modifier = Modifier - .testTag("SubmitCrashLogsSwitch") - .fillMaxWidth() - .standardHorizontalMargin(), - ) - } + CrashLogsCard( + isVisible = state.shouldShowCrashLogsButton, + isEnabled = state.isSubmitCrashLogsEnabled, + onSubmitCrashLogsCheckedChange = onSubmitCrashLogsCheckedChange, + ) + FlightRecorderCard( + isVisible = state.shouldShowFlightRecorder, + isFlightRecorderEnabled = state.isFlightRecorderEnabled, + onFlightRecorderCheckedChange = onFlightRecorderCheckedChange, + onFlightRecorderTooltipClick = onFlightRecorderTooltipClick, + onViewRecordedLogsClick = onViewRecordedLogsClick, + ) BitwardenExternalLinkRow( text = stringResource(id = R.string.bitwarden_help_center), onConfirmClick = onHelpCenterClick, @@ -160,11 +180,7 @@ private fun ContentColumn( id = R.string.learn_more_about_how_to_use_bitwarden_on_the_help_center, ), withDivider = false, - cardStyle = if (state.shouldShowCrashLogsButton) { - CardStyle.Middle() - } else { - CardStyle.Top() - }, + cardStyle = CardStyle.Top(), modifier = Modifier .standardHorizontalMargin() .fillMaxWidth() @@ -239,6 +255,62 @@ private fun ContentColumn( } } +@Composable +private fun ColumnScope.CrashLogsCard( + isVisible: Boolean, + isEnabled: Boolean, + onSubmitCrashLogsCheckedChange: (Boolean) -> Unit, +) { + if (!isVisible) return + BitwardenSwitch( + label = stringResource(id = R.string.submit_crash_logs), + contentDescription = stringResource(id = R.string.submit_crash_logs), + isChecked = isEnabled, + onCheckedChange = onSubmitCrashLogsCheckedChange, + cardStyle = CardStyle.Full, + modifier = Modifier + .testTag(tag = "SubmitCrashLogsSwitch") + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(height = 8.dp)) +} + +@Composable +private fun ColumnScope.FlightRecorderCard( + isVisible: Boolean, + isFlightRecorderEnabled: Boolean, + onFlightRecorderCheckedChange: (Boolean) -> Unit, + onFlightRecorderTooltipClick: () -> Unit, + onViewRecordedLogsClick: () -> Unit, +) { + if (!isVisible) return + BitwardenSwitch( + label = stringResource(id = R.string.flight_recorder), + isChecked = isFlightRecorderEnabled, + onCheckedChange = onFlightRecorderCheckedChange, + tooltip = TooltipData( + contentDescription = stringResource(id = R.string.flight_recorder_help), + onClick = onFlightRecorderTooltipClick, + ), + cardStyle = CardStyle.Top(), + modifier = Modifier + .testTag(tag = "FlightRecorderSwitch") + .fillMaxWidth() + .standardHorizontalMargin(), + ) + BitwardenPushRow( + text = stringResource(id = R.string.view_recorded_logs), + onClick = onViewRecordedLogsClick, + cardStyle = CardStyle.Bottom, + modifier = Modifier + .testTag(tag = "ViewRecordedLogs") + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(height = 8.dp)) +} + @Composable private fun CopyRow( text: Text, @@ -263,11 +335,28 @@ private fun CopyRow( @Preview @Composable -private fun CopyRow_preview() { +private fun AboutScreenContent_preview() { BitwardenTheme { - CopyRow( - text = "Copyable Text".asText(), - onClick = { }, + AboutScreenContent( + state = AboutState( + version = "Version: 1.0.0 (1)".asText(), + deviceData = "device_data".asText(), + ciData = "ci_data".asText(), + isSubmitCrashLogsEnabled = false, + copyrightInfo = "".asText(), + shouldShowCrashLogsButton = true, + isFlightRecorderEnabled = true, + shouldShowFlightRecorder = true, + ), + onHelpCenterClick = {}, + onPrivacyPolicyClick = {}, + onLearnAboutOrgsClick = {}, + onSubmitCrashLogsCheckedChange = { }, + onFlightRecorderCheckedChange = { }, + onFlightRecorderTooltipClick = {}, + onViewRecordedLogsClick = {}, + onVersionClick = {}, + onWebVaultClick = {}, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModel.kt index 23e9f43459..01f90641c1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModel.kt @@ -4,8 +4,10 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.LogsManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault import com.x8bit.bitwarden.data.platform.util.ciBuildInfo @@ -18,6 +20,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.concat import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.parcelize.Parcelize @@ -30,10 +33,12 @@ private const val KEY_STATE = "state" /** * View model for the about screen. */ +@Suppress("TooManyFunctions") @HiltViewModel class AboutViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private val clipboardManager: BitwardenClipboardManager, + featureFlagManager: FeatureFlagManager, clock: Clock, private val logsManager: LogsManager, private val environmentRepository: EnvironmentRepository, @@ -45,6 +50,8 @@ class AboutViewModel @Inject constructor( ciData = ciBuildInfo?.let { "\n$it" }.orEmpty().asText(), isSubmitCrashLogsEnabled = logsManager.isEnabled, shouldShowCrashLogsButton = !isFdroid, + isFlightRecorderEnabled = false, + shouldShowFlightRecorder = featureFlagManager.getFeatureFlag(FlagKey.FlightRecorder), copyrightInfo = "© Bitwarden Inc. 2015-${Year.now(clock).value}".asText(), ), ) { @@ -52,6 +59,11 @@ class AboutViewModel @Inject constructor( stateFlow .onEach { savedStateHandle[KEY_STATE] = it } .launchIn(viewModelScope) + featureFlagManager + .getFeatureFlagFlow(FlagKey.FlightRecorder) + .map { AboutAction.Internal.FlightRecorderReceive(isEnabled = it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: AboutAction): Unit = when (action) { @@ -62,6 +74,20 @@ class AboutViewModel @Inject constructor( is AboutAction.SubmitCrashLogsClick -> handleSubmitCrashLogsClick(action) AboutAction.VersionClick -> handleVersionClick() AboutAction.WebVaultClick -> handleWebVaultClick() + is AboutAction.FlightRecorderCheckedChange -> handleFlightRecorderCheckedChange(action) + AboutAction.FlightRecorderTooltipClick -> handleFlightRecorderTooltipClick() + AboutAction.ViewRecordedLogsClick -> handleViewRecordedLogsClick() + is AboutAction.Internal -> handleInternalAction(action) + } + + private fun handleInternalAction(action: AboutAction.Internal) { + when (action) { + is AboutAction.Internal.FlightRecorderReceive -> handleFlightRecorderReceive(action) + } + } + + private fun handleFlightRecorderReceive(action: AboutAction.Internal.FlightRecorderReceive) { + mutableStateFlow.update { it.copy(shouldShowFlightRecorder = action.isEnabled) } } private fun handleBackClick() { @@ -105,6 +131,22 @@ class AboutViewModel @Inject constructor( ), ) } + + private fun handleFlightRecorderCheckedChange(action: AboutAction.FlightRecorderCheckedChange) { + if (action.isEnabled) { + sendEvent(AboutEvent.NavigateToFlightRecorder) + } else { + // TODO: PM-19592 Disable the feature. + } + } + + private fun handleFlightRecorderTooltipClick() { + sendEvent(AboutEvent.NavigateToFlightRecorderHelp) + } + + private fun handleViewRecordedLogsClick() { + sendEvent(AboutEvent.NavigateToRecordedLogs) + } } /** @@ -117,6 +159,8 @@ data class AboutState( val ciData: Text, val isSubmitCrashLogsEnabled: Boolean, val shouldShowCrashLogsButton: Boolean, + val isFlightRecorderEnabled: Boolean, + val shouldShowFlightRecorder: Boolean, val copyrightInfo: Text, ) : Parcelable @@ -129,6 +173,21 @@ sealed class AboutEvent { */ data object NavigateBack : AboutEvent() + /** + * Navigates to the flight recorder configuration. + */ + data object NavigateToFlightRecorder : AboutEvent() + + /** + * Navigates to the flight recorder help info. + */ + data object NavigateToFlightRecorderHelp : AboutEvent() + + /** + * Navigates to the flight recorder log history. + */ + data object NavigateToRecordedLogs : AboutEvent() + /** * Navigates to the help center. */ @@ -190,4 +249,31 @@ sealed class AboutAction { * User clicked the web vault row. */ data object WebVaultClick : AboutAction() + + /** + * User clicked the flight recorder check box. + */ + data class FlightRecorderCheckedChange( + val isEnabled: Boolean, + ) : AboutAction() + + /** + * User clicked the flight recorder tooltip. + */ + data object FlightRecorderTooltipClick : AboutAction() + + /** + * User clicked the view recorded logs row. + */ + data object ViewRecordedLogsClick : AboutAction() + + /** + * Actions for internal use by the ViewModel. + */ + sealed class Internal : AboutAction() { + /** + * Indicates that the flight recorder feature has been enabled or disabled. + */ + data class FlightRecorderReceive(val isEnabled: Boolean) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderNavigation.kt new file mode 100644 index 0000000000..a6a11f0ccb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderNavigation.kt @@ -0,0 +1,30 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.ui.platform.base.util.composableWithPushTransitions + +private const val FLIGHT_RECORDER_ROUTE = "flight_recorder_config" + +/** + * Add flight recorder destination to the nav graph. + */ +fun NavGraphBuilder.flightRecorderDestination( + onNavigateBack: () -> Unit, +) { + composableWithPushTransitions( + route = FLIGHT_RECORDER_ROUTE, + ) { + FlightRecorderScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the flight recorder screen. + */ +fun NavController.navigateToFlightRecorder(navOptions: NavOptions? = null) { + navigate(FLIGHT_RECORDER_ROUTE, navOptions) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderScreen.kt new file mode 100644 index 0000000000..a835f4f4b1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderScreen.kt @@ -0,0 +1,49 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter + +/** + * Displays the flight recorder configuration screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FlightRecorderScreen( + onNavigateBack: () -> Unit, + viewModel: FlightRecorderViewModel = hiltViewModel(), +) { + EventsEffect(viewModel) { event -> + when (event) { + FlightRecorderEvent.NavigateBack -> onNavigateBack() + } + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.enable_flight_recorder_title), + navigationIcon = rememberVectorPainter(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(FlightRecorderAction.BackClick) } + }, + scrollBehavior = scrollBehavior, + ) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { + // TODO: PM-19592 Create the flight recorder UI. + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderViewModel.kt new file mode 100644 index 0000000000..85151ae7c9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderViewModel.kt @@ -0,0 +1,54 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder + +import androidx.lifecycle.SavedStateHandle +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * View model for the flight recorder configuration screen. + */ +@HiltViewModel +class FlightRecorderViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + // We load the state from the savedStateHandle for testing purposes. + initialState = savedStateHandle[KEY_STATE] ?: FlightRecorderState, +) { + override fun handleAction(action: FlightRecorderAction) { + when (action) { + FlightRecorderAction.BackClick -> handleBackClick() + } + } + + private fun handleBackClick() { + sendEvent(FlightRecorderEvent.NavigateBack) + } +} + +/** + * Models the UI state for the flight recorder screen. + */ +data object FlightRecorderState + +/** + * Models events for the flight recorder screen. + */ +sealed class FlightRecorderEvent { + /** + * Navigates back. + */ + data object NavigateBack : FlightRecorderEvent() +} + +/** + * Models actions for the flight recorder screen. + */ +sealed class FlightRecorderAction { + /** + * Indicates that the user clicked the close button. + */ + data object BackClick : FlightRecorderAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsNavigation.kt new file mode 100644 index 0000000000..97998419aa --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsNavigation.kt @@ -0,0 +1,30 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.ui.platform.base.util.composableWithPushTransitions + +private const val FLIGHT_RECORDER_RECORDED_LOGS_ROUTE = "flight_recorder_recorded_logs" + +/** + * Add recorded logs destination to the nav graph. + */ +fun NavGraphBuilder.recordedLogsDestination( + onNavigateBack: () -> Unit, +) { + composableWithPushTransitions( + route = FLIGHT_RECORDER_RECORDED_LOGS_ROUTE, + ) { + RecordedLogsScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the flight recorder recorded logs screen. + */ +fun NavController.navigateToRecordedLogs(navOptions: NavOptions? = null) { + navigate(FLIGHT_RECORDER_RECORDED_LOGS_ROUTE, navOptions) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsScreen.kt new file mode 100644 index 0000000000..4aa6615f39 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsScreen.kt @@ -0,0 +1,49 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter + +/** + * Displays the flight recorder recorded logs screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecordedLogsScreen( + onNavigateBack: () -> Unit, + viewModel: RecordedLogsViewModel = hiltViewModel(), +) { + EventsEffect(viewModel) { event -> + when (event) { + RecordedLogsEvent.NavigateBack -> onNavigateBack() + } + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.recorded_logs_title), + navigationIcon = rememberVectorPainter(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(RecordedLogsAction.BackClick) } + }, + scrollBehavior = scrollBehavior, + ) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { + // TODO: PM-19593 Create the flight recorder UI. + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsViewModel.kt new file mode 100644 index 0000000000..8f817a3955 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedLogs/RecordedLogsViewModel.kt @@ -0,0 +1,54 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs + +import androidx.lifecycle.SavedStateHandle +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * View model for the flight recorder recorded logs screen. + */ +@HiltViewModel +class RecordedLogsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + // We load the state from the savedStateHandle for testing purposes. + initialState = savedStateHandle[KEY_STATE] ?: RecordedLogsState, +) { + override fun handleAction(action: RecordedLogsAction) { + when (action) { + RecordedLogsAction.BackClick -> handleBackClick() + } + } + + private fun handleBackClick() { + sendEvent(RecordedLogsEvent.NavigateBack) + } +} + +/** + * Models the UI state for the recorded logs screen. + */ +data object RecordedLogsState + +/** + * Models events for the recorded logs screen. + */ +sealed class RecordedLogsEvent { + /** + * Navigates back. + */ + data object NavigateBack : RecordedLogsEvent() +} + +/** + * Models actions for the recorded logs screen. + */ +sealed class RecordedLogsAction { + /** + * Indicates that the user clicked the close button. + */ + data object BackClick : RecordedLogsAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index a042f38b21..a7766f8559 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -23,6 +23,10 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingr import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.pendingRequestsDestination import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.exportVaultDestination import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.navigateToExportVault +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.flightRecorderDestination +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.navigateToFlightRecorder +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.navigateToRecordedLogs +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.recordedLogsDestination import com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit.folderAddEditDestination import com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit.navigateToFolderAddEdit import com.x8bit.bitwarden.ui.platform.feature.settings.folders.foldersDestination @@ -115,7 +119,11 @@ fun NavGraphBuilder.vaultUnlockedGraph( parentFolderName = it, ) }, + onNavigateToFlightRecorder = { navController.navigateToFlightRecorder() }, + onNavigateToRecordedLogs = { navController.navigateToRecordedLogs() }, ) + flightRecorderDestination(onNavigateBack = { navController.popBackStack() }) + recordedLogsDestination(onNavigateBack = { navController.popBackStack() }) deleteAccountDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToDeleteAccountConfirmation = { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt index 71c81dfc70..e623eb6d29 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt @@ -40,6 +40,8 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToPasswordHistory: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit, + onNavigateToFlightRecorder: () -> Unit, + onNavigateToRecordedLogs: () -> Unit, onNavigateToImportLogins: (SnackbarRelay) -> Unit, onNavigateToAddFolderScreen: (selectedFolderName: String?) -> Unit, ) { @@ -63,6 +65,8 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToSetupAutoFillScreen = onNavigateToSetupAutoFillScreen, onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToFlightRecorder = onNavigateToFlightRecorder, + onNavigateToRecordedLogs = onNavigateToRecordedLogs, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index 488abcf71c..f7df72141b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -64,6 +64,8 @@ fun VaultUnlockedNavBarScreen( onNavigateToPasswordHistory: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit, + onNavigateToFlightRecorder: () -> Unit, + onNavigateToRecordedLogs: () -> Unit, onNavigateToImportLogins: (SnackbarRelay) -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, ) { @@ -133,6 +135,8 @@ fun VaultUnlockedNavBarScreen( onNavigateToSetupAutoFillScreen = onNavigateToSetupAutoFillScreen, onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToFlightRecorder = onNavigateToFlightRecorder, + onNavigateToRecordedLogs = onNavigateToRecordedLogs, ) } @@ -162,6 +166,8 @@ private fun VaultUnlockedNavBarScaffold( navigateToPasswordHistory: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit, + onNavigateToFlightRecorder: () -> Unit, + onNavigateToRecordedLogs: () -> Unit, onNavigateToImportLogins: (SnackbarRelay) -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, ) { @@ -237,6 +243,8 @@ private fun VaultUnlockedNavBarScaffold( onNavigateToSetupUnlockScreen = onNavigateToSetupUnlockScreen, onNavigateToSetupAutoFillScreen = onNavigateToSetupAutoFillScreen, onNavigateToImportLogins = onNavigateToImportLogins, + onNavigateToFlightRecorder = onNavigateToFlightRecorder, + onNavigateToRecordedLogs = onNavigateToRecordedLogs, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b5b81200d5..8536f4b0cc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1228,4 +1228,9 @@ Do you want to switch to this account? Add field %s... Share error details + Flight recorder + Flight recorder help + View recorded logs + Enable flight recorder + Recorded logs diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreenTest.kt index 22eb3de3f2..52e494fea9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutScreenTest.kt @@ -35,17 +35,10 @@ import java.time.ZoneOffset class AboutScreenTest : BaseComposeTest() { private var haveCalledNavigateBack = false + private var haveCalledNavigateToFlightRecorder = false + private var haveCalledNavigateToRecordedLogs = false - private val mutableStateFlow = MutableStateFlow( - AboutState( - version = "Version: 1.0.0 (1)".asText(), - deviceData = "device_data".asText(), - ciData = "ci_data".asText(), - isSubmitCrashLogsEnabled = false, - copyrightInfo = "".asText(), - shouldShowCrashLogsButton = true, - ), - ) + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val mutableEventFlow = bufferedMutableSharedFlow() val viewModel: AboutViewModel = mockk { every { stateFlow } returns mutableStateFlow @@ -65,6 +58,8 @@ class AboutScreenTest : BaseComposeTest() { AboutScreen( viewModel = viewModel, onNavigateBack = { haveCalledNavigateBack = true }, + onNavigateToFlightRecorder = { haveCalledNavigateToFlightRecorder = true }, + onNavigateToRecordedLogs = { haveCalledNavigateToRecordedLogs = true }, ) } } @@ -75,6 +70,39 @@ class AboutScreenTest : BaseComposeTest() { verify { viewModel.trySendAction(AboutAction.BackClick) } } + @Test + fun `on flight recorder tooltip click should emit FlightRecorderTooltipClick`() { + composeTestRule + .onNodeWithContentDescription("Flight recorder help") + .performScrollTo() + .performClick() + verify { + viewModel.trySendAction(AboutAction.FlightRecorderTooltipClick) + } + } + + @Test + fun `on view recorded logs click should emit ViewRecordedLogsClick`() { + composeTestRule + .onNodeWithText("View recorded logs") + .performScrollTo() + .performClick() + verify { + viewModel.trySendAction(AboutAction.ViewRecordedLogsClick) + } + } + + @Test + fun `on view recorded logs click should emit FlightRecorderCheckedChange`() { + composeTestRule + .onNodeWithText("Flight recorder") + .performScrollTo() + .performClick() + verify { + viewModel.trySendAction(AboutAction.FlightRecorderCheckedChange(isEnabled = true)) + } + } + @Suppress("MaxLineLength") @Test fun `on bitwarden help center click should display confirmation dialog and confirm click should emit HelpCenterClick`() { @@ -127,7 +155,10 @@ class AboutScreenTest : BaseComposeTest() { @Test fun `on learn about organizations click should display confirmation dialog and confirm click should emit LearnAboutOrganizationsClick`() { composeTestRule.onNode(isDialog()).assertDoesNotExist() - composeTestRule.onNodeWithText("Learn about organizations").performClick() + composeTestRule + .onNodeWithText("Learn about organizations") + .performScrollTo() + .performClick() composeTestRule.onNode(isDialog()).assertExists() composeTestRule .onAllNodesWithText("Continue") @@ -145,6 +176,26 @@ class AboutScreenTest : BaseComposeTest() { assertTrue(haveCalledNavigateBack) } + @Test + fun `on NavigateToFlightRecorder should call onNavigateToFlightRecorder`() { + mutableEventFlow.tryEmit(AboutEvent.NavigateToFlightRecorder) + assertTrue(haveCalledNavigateToFlightRecorder) + } + + @Test + fun `on NavigateToRecordedLogs should call onNavigateToRecordedLogs`() { + mutableEventFlow.tryEmit(AboutEvent.NavigateToRecordedLogs) + assertTrue(haveCalledNavigateToRecordedLogs) + } + + @Test + fun `on NavigateToFlightRecorderHelp should call launchUri on IntentManager`() { + mutableEventFlow.tryEmit(AboutEvent.NavigateToFlightRecorderHelp) + verify(exactly = 1) { + intentManager.launchUri("https://bitwarden.com/help".toUri()) + } + } + @Test fun `on NavigateToHelpCenter should call launchUri on IntentManager`() { mutableEventFlow.tryEmit(AboutEvent.NavigateToHelpCenter) @@ -243,3 +294,14 @@ class AboutScreenTest : BaseComposeTest() { .assertIsDisplayed() } } + +private val DEFAULT_STATE = AboutState( + version = "Version: 1.0.0 (1)".asText(), + deviceData = "device_data".asText(), + ciData = "ci_data".asText(), + isSubmitCrashLogsEnabled = false, + shouldShowCrashLogsButton = true, + isFlightRecorderEnabled = false, + shouldShowFlightRecorder = true, + copyrightInfo = "".asText(), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModelTest.kt index 1958b13b57..75353bff4a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/about/AboutViewModelTest.kt @@ -2,8 +2,10 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.about import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.LogsManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest @@ -15,6 +17,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -33,6 +36,10 @@ class AboutViewModelTest : BaseViewModelTest() { every { isEnabled } returns false every { isEnabled = any() } just runs } + private val featureFlagManager = mockk { + every { getFeatureFlag(FlagKey.FlightRecorder) } returns true + every { getFeatureFlagFlow(FlagKey.FlightRecorder) } returns flowOf(true) + } @Test fun `on BackClick should emit NavigateBack`() = runTest { @@ -43,6 +50,44 @@ class AboutViewModelTest : BaseViewModelTest() { } } + @Test + fun `on FlightRecorderTooltipClick should emit NavigateToFlightRecorderHelp`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AboutAction.FlightRecorderTooltipClick) + assertEquals(AboutEvent.NavigateToFlightRecorderHelp, awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on FlightRecorderCheckedChange with isEnabled true should emit NavigateToFlightRecorder`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AboutAction.FlightRecorderCheckedChange(isEnabled = true)) + assertEquals(AboutEvent.NavigateToFlightRecorder, awaitItem()) + } + } + + @Test + fun `on FlightRecorderCheckedChange with isEnabled false should do nothing`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AboutAction.FlightRecorderCheckedChange(isEnabled = false)) + expectNoEvents() + } + } + + @Test + fun `on ViewRecordedLogsClick should emit NavigateToRecordedLogs`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AboutAction.ViewRecordedLogsClick) + assertEquals(AboutEvent.NavigateToRecordedLogs, awaitItem()) + } + } + @Test fun `on HelpCenterClick should emit NavigateToHelpCenter`() = runTest { val viewModel = createViewModel(DEFAULT_ABOUT_STATE) @@ -133,6 +178,7 @@ class AboutViewModelTest : BaseViewModelTest() { clock = fixedClock, environmentRepository = environmentRepository, logsManager = logsManager, + featureFlagManager = featureFlagManager, ) } @@ -144,7 +190,9 @@ private val DEFAULT_ABOUT_STATE: AboutState = AboutState( version = "Version: ()".asText(), deviceData = "".asText(), ciData = "\n".asText(), - isSubmitCrashLogsEnabled = false, copyrightInfo = "© Bitwarden Inc. 2015-${Year.now(fixedClock).value}".asText(), + isSubmitCrashLogsEnabled = false, shouldShowCrashLogsButton = true, + isFlightRecorderEnabled = false, + shouldShowFlightRecorder = true, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderScreenTest.kt new file mode 100644 index 0000000000..da8fdd7a2d --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderScreenTest.kt @@ -0,0 +1,52 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder + +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class FlightRecorderScreenTest : BaseComposeTest() { + private var onNavigateBackCalled = false + private val mutableEventFlow = bufferedMutableSharedFlow() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setUp() { + setContent { + FlightRecorderScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `on navigation icon click should emit BackClick action`() { + composeTestRule + .onNodeWithContentDescription(label = "Close") + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(FlightRecorderAction.BackClick) + } + } + + @Test + fun `on NavigateBack event should invoke onNavigateBack`() { + mutableEventFlow.tryEmit(FlightRecorderEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } +} + +private val DEFAULT_STATE: FlightRecorderState = FlightRecorderState diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderViewModelTest.kt new file mode 100644 index 0000000000..9f3c9a79b1 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/FlightRecorderViewModelTest.kt @@ -0,0 +1,37 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder + +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 FlightRecorderViewModelTest : BaseViewModelTest() { + + @Test + fun `initial state should be correct`() = runTest { + val viewModel = createViewModel() + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + fun `on BackClick action should send the NavigateBack event`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(FlightRecorderAction.BackClick) + assertEquals(FlightRecorderEvent.NavigateBack, awaitItem()) + } + } + + private fun createViewModel( + state: FlightRecorderState? = null, + ): FlightRecorderViewModel = + FlightRecorderViewModel( + savedStateHandle = SavedStateHandle().apply { + set("state", state) + }, + ) +} + +private val DEFAULT_STATE: FlightRecorderState = FlightRecorderState diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsScreenTest.kt new file mode 100644 index 0000000000..b2d39ce384 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsScreenTest.kt @@ -0,0 +1,57 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedlogs + +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsAction +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsEvent +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsScreen +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsState +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsViewModel +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class RecordedLogsScreenTest : BaseComposeTest() { + private var onNavigateBackCalled = false + private val mutableEventFlow = bufferedMutableSharedFlow() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setUp() { + setContent { + RecordedLogsScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `on navigation icon click should emit BackClick action`() { + composeTestRule + .onNodeWithContentDescription(label = "Close") + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(RecordedLogsAction.BackClick) + } + } + + @Test + fun `on NavigateBack event should invoke onNavigateBack`() { + mutableEventFlow.tryEmit(RecordedLogsEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } +} + +private val DEFAULT_STATE: RecordedLogsState = RecordedLogsState diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsViewModelTest.kt new file mode 100644 index 0000000000..9f524d3169 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/flightrecorder/recordedlogs/RecordedLogsViewModelTest.kt @@ -0,0 +1,41 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedlogs + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsAction +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsEvent +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsState +import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.RecordedLogsViewModel +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class RecordedLogsViewModelTest : BaseViewModelTest() { + + @Test + fun `initial state should be correct`() = runTest { + val viewModel = createViewModel() + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + fun `on BackClick action should send the NavigateBack event`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(RecordedLogsAction.BackClick) + assertEquals(RecordedLogsEvent.NavigateBack, awaitItem()) + } + } + + private fun createViewModel( + state: RecordedLogsState? = null, + ): RecordedLogsViewModel = + RecordedLogsViewModel( + savedStateHandle = SavedStateHandle().apply { + set("state", state) + }, + ) +} + +private val DEFAULT_STATE: RecordedLogsState = RecordedLogsState diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt index 618469c84d..a51e4f61a2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt @@ -57,6 +57,8 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() { onNavigateToSetupUnlockScreen = {}, onNavigateToImportLogins = {}, onNavigateToAddFolderScreen = {}, + onNavigateToFlightRecorder = {}, + onNavigateToRecordedLogs = {}, ) } }