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

View File

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

View File

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

View File

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

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.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 = {},
)
}
}

View File

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

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.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 = {

View File

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

View File

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

View File

@ -1228,4 +1228,9 @@ Do you want to switch to this account?</string>
<string name="add_field">Add field</string>
<string name="x_ellipses">%s...</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>

View File

@ -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<AboutEvent>()
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(),
)

View File

@ -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<FeatureFlagManager> {
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: <version_name> (<version_code>)".asText(),
deviceData = "<device_data>".asText(),
ciData = "\n<ci_info>".asText(),
isSubmitCrashLogsEnabled = false,
copyrightInfo = "© Bitwarden Inc. 2015-${Year.now(fixedClock).value}".asText(),
isSubmitCrashLogsEnabled = false,
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 = {},
onNavigateToImportLogins = {},
onNavigateToAddFolderScreen = {},
onNavigateToFlightRecorder = {},
onNavigateToRecordedLogs = {},
)
}
}