PM-19591: Initial flight recorder UI (#4970)

This commit is contained in:
David Perez 2025-04-03 08:42:13 -05:00 committed by GitHub
parent 1e6f896328
commit 1fecd4af5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 966 additions and 166 deletions

View File

@ -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,
)
}
}
}

View File

@ -254,7 +254,7 @@ fun BitwardenSwitch(
cardStyle = cardStyle, cardStyle = cardStyle,
onClick = onCheckedChange?.let { { it(!isChecked) } }, onClick = onCheckedChange?.let { { it(!isChecked) } },
clickEnabled = !readOnly && enabled, clickEnabled = !readOnly && enabled,
paddingTop = 12.dp, paddingTop = 6.dp,
paddingBottom = 0.dp, paddingBottom = 0.dp,
) )
.semantics(mergeDescendants = true) { .semantics(mergeDescendants = true) {
@ -264,7 +264,7 @@ fun BitwardenSwitch(
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.defaultMinSize(minHeight = 36.dp), modifier = Modifier.defaultMinSize(minHeight = 48.dp),
) { ) {
Spacer(modifier = Modifier.width(width = 16.dp)) Spacer(modifier = Modifier.width(width = 16.dp))
Row( Row(
@ -329,7 +329,7 @@ fun BitwardenSwitch(
content = content, content = content,
) )
} }
?: Spacer(modifier = Modifier.height(height = cardStyle?.let { 12.dp } ?: 0.dp)) ?: Spacer(modifier = Modifier.height(height = cardStyle?.let { 6.dp } ?: 0.dp))
} }
} }

View File

@ -36,6 +36,8 @@ fun NavGraphBuilder.settingsGraph(
onNavigateToPendingRequests: () -> Unit, onNavigateToPendingRequests: () -> Unit,
onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit,
onNavigateToSetupAutoFillScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit,
onNavigateToFlightRecorder: () -> Unit,
onNavigateToRecordedLogs: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit, onNavigateToImportLogins: (SnackbarRelay) -> Unit,
) { ) {
navigation( navigation(
@ -54,7 +56,11 @@ fun NavGraphBuilder.settingsGraph(
onNavigateToVault = { navController.navigateToVaultSettings() }, onNavigateToVault = { navController.navigateToVaultSettings() },
) )
} }
aboutDestination(onNavigateBack = { navController.popBackStack() }) aboutDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
)
accountSecurityDestination( accountSecurityDestination(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToDeleteAccount = onNavigateToDeleteAccount, onNavigateToDeleteAccount = onNavigateToDeleteAccount,

View File

@ -1,51 +1,34 @@
package com.x8bit.bitwarden.ui.platform.feature.settings 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding 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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect 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.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.base.util.toListItemCardStyle 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.appbar.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.badge.NotificationBadge import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle 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.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/** /**
* Displays the settings screen. * Displays the settings screen.
@ -91,8 +74,8 @@ fun SettingsScreen(
) { ) {
Spacer(modifier = Modifier.height(height = 12.dp)) Spacer(modifier = Modifier.height(height = 12.dp))
Settings.entries.forEachIndexed { index, settingEntry -> Settings.entries.forEachIndexed { index, settingEntry ->
SettingsRow( BitwardenPushRow(
text = settingEntry.text, text = settingEntry.text(),
onClick = remember(viewModel) { onClick = remember(viewModel) {
{ viewModel.trySendAction(SettingsAction.SettingsClick(settingEntry)) } { viewModel.trySendAction(SettingsAction.SettingsClick(settingEntry)) }
}, },
@ -105,7 +88,7 @@ fun SettingsScreen(
// Start padding, plus icon, plus spacing between text. // Start padding, plus icon, plus spacing between text.
dividerPadding = 48.dp, dividerPadding = 48.dp,
), ),
iconVectorResource = settingEntry.vectorIconRes, leadingIcon = IconData.Local(iconRes = settingEntry.vectorIconRes),
modifier = Modifier modifier = Modifier
.testTag(tag = settingEntry.testTag) .testTag(tag = settingEntry.testTag)
.standardHorizontalMargin() .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,
),
)
}
}
}
}

View File

@ -12,11 +12,17 @@ private const val ABOUT_ROUTE = "settings_about"
*/ */
fun NavGraphBuilder.aboutDestination( fun NavGraphBuilder.aboutDestination(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToFlightRecorder: () -> Unit,
onNavigateToRecordedLogs: () -> Unit,
) { ) {
composableWithPushTransitions( composableWithPushTransitions(
route = ABOUT_ROUTE, route = ABOUT_ROUTE,
) { ) {
AboutScreen(onNavigateBack = onNavigateBack) AboutScreen(
onNavigateBack = onNavigateBack,
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
)
} }
} }

View File

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.about
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize 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.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar 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.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.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.row.BitwardenTextRow
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold 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.toggle.BitwardenSwitch
@ -54,17 +57,23 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@Composable @Composable
fun AboutScreen( fun AboutScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToFlightRecorder: () -> Unit,
onNavigateToRecordedLogs: () -> Unit,
viewModel: AboutViewModel = hiltViewModel(), viewModel: AboutViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current, intentManager: IntentManager = LocalIntentManager.current,
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
when (event) { when (event) {
is AboutEvent.NavigateToWebVault -> { is AboutEvent.NavigateToWebVault -> intentManager.launchUri(event.vaultUrl.toUri())
intentManager.launchUri(event.vaultUrl.toUri())
}
AboutEvent.NavigateBack -> onNavigateBack.invoke() 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 -> { AboutEvent.NavigateToHelpCenter -> {
intentManager.launchUri("https://bitwarden.com/help".toUri()) intentManager.launchUri("https://bitwarden.com/help".toUri())
@ -97,7 +106,7 @@ fun AboutScreen(
) )
}, },
) { ) {
ContentColumn( AboutScreenContent(
state = state, state = state,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onHelpCenterClick = remember(viewModel) { onHelpCenterClick = remember(viewModel) {
@ -112,6 +121,15 @@ fun AboutScreen(
onSubmitCrashLogsCheckedChange = remember(viewModel) { onSubmitCrashLogsCheckedChange = remember(viewModel) {
{ viewModel.trySendAction(AboutAction.SubmitCrashLogsClick(it)) } { 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) { onVersionClick = remember(viewModel) {
{ viewModel.trySendAction(AboutAction.VersionClick) } { viewModel.trySendAction(AboutAction.VersionClick) }
}, },
@ -124,12 +142,15 @@ fun AboutScreen(
@Suppress("LongMethod") @Suppress("LongMethod")
@Composable @Composable
private fun ContentColumn( private fun AboutScreenContent(
state: AboutState, state: AboutState,
onHelpCenterClick: () -> Unit, onHelpCenterClick: () -> Unit,
onPrivacyPolicyClick: () -> Unit, onPrivacyPolicyClick: () -> Unit,
onLearnAboutOrgsClick: () -> Unit, onLearnAboutOrgsClick: () -> Unit,
onSubmitCrashLogsCheckedChange: (Boolean) -> Unit, onSubmitCrashLogsCheckedChange: (Boolean) -> Unit,
onFlightRecorderCheckedChange: (Boolean) -> Unit,
onFlightRecorderTooltipClick: () -> Unit,
onViewRecordedLogsClick: () -> Unit,
onVersionClick: () -> Unit, onVersionClick: () -> Unit,
onWebVaultClick: () -> Unit, onWebVaultClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -139,19 +160,18 @@ private fun ContentColumn(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
Spacer(modifier = Modifier.height(height = 12.dp)) Spacer(modifier = Modifier.height(height = 12.dp))
if (state.shouldShowCrashLogsButton) { CrashLogsCard(
BitwardenSwitch( isVisible = state.shouldShowCrashLogsButton,
label = stringResource(id = R.string.submit_crash_logs), isEnabled = state.isSubmitCrashLogsEnabled,
contentDescription = stringResource(id = R.string.submit_crash_logs), onSubmitCrashLogsCheckedChange = onSubmitCrashLogsCheckedChange,
isChecked = state.isSubmitCrashLogsEnabled, )
onCheckedChange = onSubmitCrashLogsCheckedChange, FlightRecorderCard(
cardStyle = CardStyle.Top(), isVisible = state.shouldShowFlightRecorder,
modifier = Modifier isFlightRecorderEnabled = state.isFlightRecorderEnabled,
.testTag("SubmitCrashLogsSwitch") onFlightRecorderCheckedChange = onFlightRecorderCheckedChange,
.fillMaxWidth() onFlightRecorderTooltipClick = onFlightRecorderTooltipClick,
.standardHorizontalMargin(), onViewRecordedLogsClick = onViewRecordedLogsClick,
) )
}
BitwardenExternalLinkRow( BitwardenExternalLinkRow(
text = stringResource(id = R.string.bitwarden_help_center), text = stringResource(id = R.string.bitwarden_help_center),
onConfirmClick = onHelpCenterClick, onConfirmClick = onHelpCenterClick,
@ -160,11 +180,7 @@ private fun ContentColumn(
id = R.string.learn_more_about_how_to_use_bitwarden_on_the_help_center, id = R.string.learn_more_about_how_to_use_bitwarden_on_the_help_center,
), ),
withDivider = false, withDivider = false,
cardStyle = if (state.shouldShowCrashLogsButton) { cardStyle = CardStyle.Top(),
CardStyle.Middle()
} else {
CardStyle.Top()
},
modifier = Modifier modifier = Modifier
.standardHorizontalMargin() .standardHorizontalMargin()
.fillMaxWidth() .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 @Composable
private fun CopyRow( private fun CopyRow(
text: Text, text: Text,
@ -263,11 +335,28 @@ private fun CopyRow(
@Preview @Preview
@Composable @Composable
private fun CopyRow_preview() { private fun AboutScreenContent_preview() {
BitwardenTheme { BitwardenTheme {
CopyRow( AboutScreenContent(
text = "Copyable Text".asText(), state = AboutState(
onClick = { }, 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 = {},
) )
} }
} }

View File

@ -4,8 +4,10 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R 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.LogsManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager 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.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault
import com.x8bit.bitwarden.data.platform.util.ciBuildInfo 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 com.x8bit.bitwarden.ui.platform.base.util.concat
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -30,10 +33,12 @@ private const val KEY_STATE = "state"
/** /**
* View model for the about screen. * View model for the about screen.
*/ */
@Suppress("TooManyFunctions")
@HiltViewModel @HiltViewModel
class AboutViewModel @Inject constructor( class AboutViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
private val clipboardManager: BitwardenClipboardManager, private val clipboardManager: BitwardenClipboardManager,
featureFlagManager: FeatureFlagManager,
clock: Clock, clock: Clock,
private val logsManager: LogsManager, private val logsManager: LogsManager,
private val environmentRepository: EnvironmentRepository, private val environmentRepository: EnvironmentRepository,
@ -45,6 +50,8 @@ class AboutViewModel @Inject constructor(
ciData = ciBuildInfo?.let { "\n$it" }.orEmpty().asText(), ciData = ciBuildInfo?.let { "\n$it" }.orEmpty().asText(),
isSubmitCrashLogsEnabled = logsManager.isEnabled, isSubmitCrashLogsEnabled = logsManager.isEnabled,
shouldShowCrashLogsButton = !isFdroid, shouldShowCrashLogsButton = !isFdroid,
isFlightRecorderEnabled = false,
shouldShowFlightRecorder = featureFlagManager.getFeatureFlag(FlagKey.FlightRecorder),
copyrightInfo = "© Bitwarden Inc. 2015-${Year.now(clock).value}".asText(), copyrightInfo = "© Bitwarden Inc. 2015-${Year.now(clock).value}".asText(),
), ),
) { ) {
@ -52,6 +59,11 @@ class AboutViewModel @Inject constructor(
stateFlow stateFlow
.onEach { savedStateHandle[KEY_STATE] = it } .onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope) .launchIn(viewModelScope)
featureFlagManager
.getFeatureFlagFlow(FlagKey.FlightRecorder)
.map { AboutAction.Internal.FlightRecorderReceive(isEnabled = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
} }
override fun handleAction(action: AboutAction): Unit = when (action) { override fun handleAction(action: AboutAction): Unit = when (action) {
@ -62,6 +74,20 @@ class AboutViewModel @Inject constructor(
is AboutAction.SubmitCrashLogsClick -> handleSubmitCrashLogsClick(action) is AboutAction.SubmitCrashLogsClick -> handleSubmitCrashLogsClick(action)
AboutAction.VersionClick -> handleVersionClick() AboutAction.VersionClick -> handleVersionClick()
AboutAction.WebVaultClick -> handleWebVaultClick() 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() { 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 ciData: Text,
val isSubmitCrashLogsEnabled: Boolean, val isSubmitCrashLogsEnabled: Boolean,
val shouldShowCrashLogsButton: Boolean, val shouldShowCrashLogsButton: Boolean,
val isFlightRecorderEnabled: Boolean,
val shouldShowFlightRecorder: Boolean,
val copyrightInfo: Text, val copyrightInfo: Text,
) : Parcelable ) : Parcelable
@ -129,6 +173,21 @@ sealed class AboutEvent {
*/ */
data object NavigateBack : 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. * Navigates to the help center.
*/ */
@ -190,4 +249,31 @@ sealed class AboutAction {
* User clicked the web vault row. * User clicked the web vault row.
*/ */
data object WebVaultClick : AboutAction() 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()
}
} }

View File

@ -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)
}

View File

@ -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.
}
}

View File

@ -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<FlightRecorderState, FlightRecorderEvent, FlightRecorderAction>(
// 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()
}

View File

@ -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)
}

View File

@ -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.
}
}

View File

@ -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<RecordedLogsState, RecordedLogsEvent, RecordedLogsAction>(
// 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()
}

View File

@ -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.accountsecurity.pendingrequests.pendingRequestsDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.exportVaultDestination 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.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.folderAddEditDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit.navigateToFolderAddEdit import com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit.navigateToFolderAddEdit
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.foldersDestination import com.x8bit.bitwarden.ui.platform.feature.settings.folders.foldersDestination
@ -115,7 +119,11 @@ fun NavGraphBuilder.vaultUnlockedGraph(
parentFolderName = it, parentFolderName = it,
) )
}, },
onNavigateToFlightRecorder = { navController.navigateToFlightRecorder() },
onNavigateToRecordedLogs = { navController.navigateToRecordedLogs() },
) )
flightRecorderDestination(onNavigateBack = { navController.popBackStack() })
recordedLogsDestination(onNavigateBack = { navController.popBackStack() })
deleteAccountDestination( deleteAccountDestination(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToDeleteAccountConfirmation = { onNavigateToDeleteAccountConfirmation = {

View File

@ -40,6 +40,8 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToPasswordHistory: () -> Unit, onNavigateToPasswordHistory: () -> Unit,
onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit,
onNavigateToSetupAutoFillScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit,
onNavigateToFlightRecorder: () -> Unit,
onNavigateToRecordedLogs: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit, onNavigateToImportLogins: (SnackbarRelay) -> Unit,
onNavigateToAddFolderScreen: (selectedFolderName: String?) -> Unit, onNavigateToAddFolderScreen: (selectedFolderName: String?) -> Unit,
) { ) {
@ -63,6 +65,8 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToSetupAutoFillScreen = onNavigateToSetupAutoFillScreen, onNavigateToSetupAutoFillScreen = onNavigateToSetupAutoFillScreen,
onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToImportLogins = onNavigateToImportLogins,
onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen,
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
) )
} }
} }

View File

@ -64,6 +64,8 @@ fun VaultUnlockedNavBarScreen(
onNavigateToPasswordHistory: () -> Unit, onNavigateToPasswordHistory: () -> Unit,
onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit,
onNavigateToSetupAutoFillScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit,
onNavigateToFlightRecorder: () -> Unit,
onNavigateToRecordedLogs: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit, onNavigateToImportLogins: (SnackbarRelay) -> Unit,
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
) { ) {
@ -133,6 +135,8 @@ fun VaultUnlockedNavBarScreen(
onNavigateToSetupAutoFillScreen = onNavigateToSetupAutoFillScreen, onNavigateToSetupAutoFillScreen = onNavigateToSetupAutoFillScreen,
onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToImportLogins = onNavigateToImportLogins,
onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen,
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
) )
} }
@ -162,6 +166,8 @@ private fun VaultUnlockedNavBarScaffold(
navigateToPasswordHistory: () -> Unit, navigateToPasswordHistory: () -> Unit,
onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit,
onNavigateToSetupAutoFillScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit,
onNavigateToFlightRecorder: () -> Unit,
onNavigateToRecordedLogs: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit, onNavigateToImportLogins: (SnackbarRelay) -> Unit,
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
) { ) {
@ -237,6 +243,8 @@ private fun VaultUnlockedNavBarScaffold(
onNavigateToSetupUnlockScreen = onNavigateToSetupUnlockScreen, onNavigateToSetupUnlockScreen = onNavigateToSetupUnlockScreen,
onNavigateToSetupAutoFillScreen = onNavigateToSetupAutoFillScreen, onNavigateToSetupAutoFillScreen = onNavigateToSetupAutoFillScreen,
onNavigateToImportLogins = onNavigateToImportLogins, onNavigateToImportLogins = onNavigateToImportLogins,
onNavigateToFlightRecorder = onNavigateToFlightRecorder,
onNavigateToRecordedLogs = onNavigateToRecordedLogs,
) )
} }
} }

View File

@ -1228,4 +1228,9 @@ Do you want to switch to this account?</string>
<string name="add_field">Add field</string> <string name="add_field">Add field</string>
<string name="x_ellipses">%s...</string> <string name="x_ellipses">%s...</string>
<string name="share_error_details">Share error details</string> <string name="share_error_details">Share error details</string>
<string name="flight_recorder">Flight recorder</string>
<string name="flight_recorder_help">Flight recorder help</string>
<string name="view_recorded_logs">View recorded logs</string>
<string name="enable_flight_recorder_title">Enable flight recorder</string>
<string name="recorded_logs_title">Recorded logs</string>
</resources> </resources>

View File

@ -35,17 +35,10 @@ import java.time.ZoneOffset
class AboutScreenTest : BaseComposeTest() { class AboutScreenTest : BaseComposeTest() {
private var haveCalledNavigateBack = false private var haveCalledNavigateBack = false
private var haveCalledNavigateToFlightRecorder = false
private var haveCalledNavigateToRecordedLogs = false
private val mutableStateFlow = MutableStateFlow( private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
AboutState(
version = "Version: 1.0.0 (1)".asText(),
deviceData = "device_data".asText(),
ciData = "ci_data".asText(),
isSubmitCrashLogsEnabled = false,
copyrightInfo = "".asText(),
shouldShowCrashLogsButton = true,
),
)
private val mutableEventFlow = bufferedMutableSharedFlow<AboutEvent>() private val mutableEventFlow = bufferedMutableSharedFlow<AboutEvent>()
val viewModel: AboutViewModel = mockk { val viewModel: AboutViewModel = mockk {
every { stateFlow } returns mutableStateFlow every { stateFlow } returns mutableStateFlow
@ -65,6 +58,8 @@ class AboutScreenTest : BaseComposeTest() {
AboutScreen( AboutScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { haveCalledNavigateBack = true }, onNavigateBack = { haveCalledNavigateBack = true },
onNavigateToFlightRecorder = { haveCalledNavigateToFlightRecorder = true },
onNavigateToRecordedLogs = { haveCalledNavigateToRecordedLogs = true },
) )
} }
} }
@ -75,6 +70,39 @@ class AboutScreenTest : BaseComposeTest() {
verify { viewModel.trySendAction(AboutAction.BackClick) } 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") @Suppress("MaxLineLength")
@Test @Test
fun `on bitwarden help center click should display confirmation dialog and confirm click should emit HelpCenterClick`() { fun `on bitwarden help center click should display confirmation dialog and confirm click should emit HelpCenterClick`() {
@ -127,7 +155,10 @@ class AboutScreenTest : BaseComposeTest() {
@Test @Test
fun `on learn about organizations click should display confirmation dialog and confirm click should emit LearnAboutOrganizationsClick`() { fun `on learn about organizations click should display confirmation dialog and confirm click should emit LearnAboutOrganizationsClick`() {
composeTestRule.onNode(isDialog()).assertDoesNotExist() composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText("Learn about organizations").performClick() composeTestRule
.onNodeWithText("Learn about organizations")
.performScrollTo()
.performClick()
composeTestRule.onNode(isDialog()).assertExists() composeTestRule.onNode(isDialog()).assertExists()
composeTestRule composeTestRule
.onAllNodesWithText("Continue") .onAllNodesWithText("Continue")
@ -145,6 +176,26 @@ class AboutScreenTest : BaseComposeTest() {
assertTrue(haveCalledNavigateBack) 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 @Test
fun `on NavigateToHelpCenter should call launchUri on IntentManager`() { fun `on NavigateToHelpCenter should call launchUri on IntentManager`() {
mutableEventFlow.tryEmit(AboutEvent.NavigateToHelpCenter) mutableEventFlow.tryEmit(AboutEvent.NavigateToHelpCenter)
@ -243,3 +294,14 @@ class AboutScreenTest : BaseComposeTest() {
.assertIsDisplayed() .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(),
)

View File

@ -2,8 +2,10 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.about
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test 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.LogsManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager 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.FakeEnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
@ -15,6 +17,7 @@ import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs import io.mockk.runs
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
@ -33,6 +36,10 @@ class AboutViewModelTest : BaseViewModelTest() {
every { isEnabled } returns false every { isEnabled } returns false
every { isEnabled = any() } just runs every { isEnabled = any() } just runs
} }
private val featureFlagManager = mockk<FeatureFlagManager> {
every { getFeatureFlag(FlagKey.FlightRecorder) } returns true
every { getFeatureFlagFlow(FlagKey.FlightRecorder) } returns flowOf(true)
}
@Test @Test
fun `on BackClick should emit NavigateBack`() = runTest { 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 @Test
fun `on HelpCenterClick should emit NavigateToHelpCenter`() = runTest { fun `on HelpCenterClick should emit NavigateToHelpCenter`() = runTest {
val viewModel = createViewModel(DEFAULT_ABOUT_STATE) val viewModel = createViewModel(DEFAULT_ABOUT_STATE)
@ -133,6 +178,7 @@ class AboutViewModelTest : BaseViewModelTest() {
clock = fixedClock, clock = fixedClock,
environmentRepository = environmentRepository, environmentRepository = environmentRepository,
logsManager = logsManager, logsManager = logsManager,
featureFlagManager = featureFlagManager,
) )
} }
@ -144,7 +190,9 @@ private val DEFAULT_ABOUT_STATE: AboutState = AboutState(
version = "Version: <version_name> (<version_code>)".asText(), version = "Version: <version_name> (<version_code>)".asText(),
deviceData = "<device_data>".asText(), deviceData = "<device_data>".asText(),
ciData = "\n<ci_info>".asText(), ciData = "\n<ci_info>".asText(),
isSubmitCrashLogsEnabled = false,
copyrightInfo = "© Bitwarden Inc. 2015-${Year.now(fixedClock).value}".asText(), copyrightInfo = "© Bitwarden Inc. 2015-${Year.now(fixedClock).value}".asText(),
isSubmitCrashLogsEnabled = false,
shouldShowCrashLogsButton = true, shouldShowCrashLogsButton = true,
isFlightRecorderEnabled = false,
shouldShowFlightRecorder = true,
) )

View File

@ -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<FlightRecorderEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<FlightRecorderViewModel>(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

View File

@ -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

View File

@ -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<RecordedLogsEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<RecordedLogsViewModel>(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

View File

@ -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

View File

@ -57,6 +57,8 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
onNavigateToSetupUnlockScreen = {}, onNavigateToSetupUnlockScreen = {},
onNavigateToImportLogins = {}, onNavigateToImportLogins = {},
onNavigateToAddFolderScreen = {}, onNavigateToAddFolderScreen = {},
onNavigateToFlightRecorder = {},
onNavigateToRecordedLogs = {},
) )
} }
} }