PM-19594: Add flight recorder banner (#5079)

This commit is contained in:
David Perez
2025-04-21 14:46:53 +00:00
committed by GitHub
parent 2a1669cf87
commit 8cbd7369c5
21 changed files with 551 additions and 32 deletions
@@ -43,6 +43,9 @@ data class FlightRecorderDataSet(
@SerialName("isActive")
val isActive: Boolean,
@SerialName("isBannerDismissed")
val isBannerDismissed: Boolean = false,
@SerialName("expirationTime")
val expirationTimeMs: Long? = null,
)
@@ -18,6 +18,11 @@ interface FlightRecorderManager {
*/
val flightRecorderDataFlow: StateFlow<FlightRecorderDataSet>
/**
* Dismisses the all flight recorder banners.
*/
fun dismissFlightRecorderBanner()
/**
* Starts the flight recorder for the given [duration].
*/
@@ -71,6 +71,13 @@ internal class FlightRecorderManagerImpl(
)
}
override fun dismissFlightRecorderBanner() {
val originalData = flightRecorderData
settingsDiskSource.flightRecorderData = originalData.copy(
data = originalData.data.map { it.copy(isBannerDismissed = true) }.toSet(),
)
}
override fun startFlightRecorder(duration: FlightRecorderDuration) {
val startTime = clock.instant()
val originalData = flightRecorderData
@@ -0,0 +1,50 @@
package com.x8bit.bitwarden.ui.platform.components.animation
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* An animation API that tries to work like AnimateVisibility, animating in when the content is
* present and animating out when null.
*
* @param targetState The state the animation should animate towards.
* @param modifier The [Modifier] for the component.
* @param enter The [EnterTransition] defining how the content animates in.
* @param exit The [ExitTransition] defining how the content animates out.
* @param label An optional parameter to differentiate from other animations.
* @param content The lambda that for the UI.
*/
@Composable
fun <T> AnimateNullableContentVisibility(
targetState: T?,
modifier: Modifier = Modifier,
enter: EnterTransition = fadeIn(tween(easing = LinearEasing, durationMillis = 200)),
exit: ExitTransition = fadeOut(tween(durationMillis = 200)),
sizeTransform: SizeTransform? = null,
label: String = "AnimateNullableContent",
content: @Composable AnimatedContentScope.(targetState: T) -> Unit,
) {
AnimatedContent(
content = { state -> state?.let { content(it) } },
targetState = targetState,
transitionSpec = {
ContentTransform(
targetContentEnter = enter,
initialContentExit = exit,
sizeTransform = sizeTransform,
)
},
label = label,
modifier = modifier,
)
}
@@ -1,8 +1,10 @@
package com.x8bit.bitwarden.ui.platform.components.fab
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.FloatingActionButton
@@ -19,7 +21,8 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
* @param painter The icon for the button.
* @param contentDescription The content description for the button.
* @param modifier The [Modifier] to be applied to the button.
* @param windowInsets The insets to be applied to this composable.
* @param windowInsets The insets to be applied to this composable. By default this will account for
* the insets that are on the sides and bottom of the screen (Display Cutout and Navigation bars).
*/
@Composable
fun BitwardenFloatingActionButton(
@@ -27,7 +30,9 @@ fun BitwardenFloatingActionButton(
painter: Painter,
contentDescription: String,
modifier: Modifier = Modifier,
windowInsets: WindowInsets = WindowInsets.displayCutout.union(WindowInsets.navigationBars),
windowInsets: WindowInsets = WindowInsets.displayCutout
.only(sides = WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)
.union(insets = WindowInsets.navigationBars),
) {
FloatingActionButton(
containerColor = BitwardenTheme.colorScheme.filledButton.background,
@@ -6,11 +6,18 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -18,27 +25,39 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.x8bit.bitwarden.ui.platform.components.button.color.bitwardenOutlinedButtonColors
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Custom snackbar for Bitwarden.
* Shows a message with an optional actions and title.
*
* @param bitwardenSnackbarData The data required to display the Snackbar.
* @param modifier The [Modifier] to be applied to the button.
* @param windowInsets The insets to be applied to this composable. By default this will account for
* the insets that are on the sides and bottom of the screen (Display Cutout and Navigation bars).
* @param onDismiss The callback invoked when the Snackbar is dismissed.
* @param onActionClick The callback invoked when the Snackbar action occurs.
*/
@Suppress("LongMethod")
@Composable
fun BitwardenSnackbar(
bitwardenSnackbarData: BitwardenSnackbarData,
modifier: Modifier = Modifier,
windowInsets: WindowInsets = WindowInsets.displayCutout
.only(sides = WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)
.union(insets = WindowInsets.navigationBars),
onDismiss: () -> Unit = {},
onActionClick: () -> Unit = {},
) {
Box(
modifier = modifier.padding(12.dp),
modifier = modifier
.windowInsetsPadding(insets = windowInsets)
.consumeWindowInsets(insets = windowInsets)
.padding(12.dp),
) {
Row(
modifier = Modifier
@@ -55,7 +74,7 @@ fun BitwardenSnackbar(
)
.padding(16.dp),
) {
Column {
Column(modifier = Modifier.weight(weight = 1f)) {
bitwardenSnackbarData.messageHeader?.let {
Text(
text = it(),
@@ -85,16 +104,12 @@ fun BitwardenSnackbar(
}
}
if (bitwardenSnackbarData.withDismissAction) {
Spacer(Modifier.weight(1f))
IconButton(
BitwardenStandardIconButton(
onClick = onDismiss,
content = {
Icon(
rememberVectorPainter(R.drawable.ic_close),
contentDescription = stringResource(R.string.close),
tint = BitwardenTheme.colorScheme.icon.reversed,
)
},
vectorIconRes = R.drawable.ic_close,
contentDescription = stringResource(R.string.close),
contentColor = BitwardenTheme.colorScheme.icon.reversed,
modifier = Modifier.offset(x = 12.dp, y = (-12).dp),
)
}
}
@@ -1,8 +1,11 @@
package com.x8bit.bitwarden.ui.platform.components.snackbar
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.SnackbarHost
@@ -14,17 +17,22 @@ import androidx.compose.ui.Modifier
*
* @param bitwardenHostState The state of this snackbar.
* @param modifier The [Modifier] to be applied to the [SnackbarHost].
* @param windowInsets The insets to be applied to this composable.
* @param windowInsets The insets to be applied to this composable. By default this will account for
* the insets that are on the sides and bottom of the screen (Display Cutout and Navigation bars).
*/
@Composable
fun BitwardenSnackbarHost(
bitwardenHostState: BitwardenSnackbarHostState,
modifier: Modifier = Modifier,
windowInsets: WindowInsets = WindowInsets.displayCutout.union(WindowInsets.navigationBars),
windowInsets: WindowInsets = WindowInsets.displayCutout
.only(sides = WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)
.union(insets = WindowInsets.navigationBars),
) {
SnackbarHost(
hostState = bitwardenHostState.snackbarHostState,
modifier = modifier.windowInsetsPadding(insets = windowInsets),
modifier = modifier
.windowInsetsPadding(insets = windowInsets)
.consumeWindowInsets(insets = windowInsets),
) { snackbarData ->
val message = snackbarData.visuals.message
val currentCustomSnackbarData = bitwardenHostState.currentSnackbarData
@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.platform.components.snackbar
import android.os.Parcelable
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
@@ -14,6 +15,7 @@ import androidx.compose.runtime.setValue
import com.bitwarden.ui.util.Text
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
/**
* A custom state holder for [BitwardenSnackbarData] and manging a snackbar host with the
@@ -65,13 +67,14 @@ data class BitwardenSnackbarHostState(
* @property key The unique key for the [BitwardenSnackbarData].
*/
@Immutable
@Parcelize
data class BitwardenSnackbarData(
val message: Text,
val messageHeader: Text? = null,
val actionLabel: Text? = null,
val withDismissAction: Boolean = false,
) {
val key: String = this.hashCode().toString()
) : Parcelable {
val key: String get() = this.hashCode().toString()
}
/**
@@ -1,8 +1,10 @@
package com.x8bit.bitwarden.ui.platform.feature.settings
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.navOptions
import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.platform.base.util.composableWithRootPushTransitions
import com.x8bit.bitwarden.ui.platform.feature.settings.about.aboutDestination
@@ -85,8 +87,26 @@ fun NavGraphBuilder.settingsGraph(
}
/**
* Navigate to the settings screen.
* Navigate to the settings graph.
*/
fun NavController.navigateToSettingsGraph(navOptions: NavOptions? = null) {
navigate(SETTINGS_GRAPH_ROUTE, navOptions)
}
/**
* Navigate to the settings graph root.
*/
fun NavController.navigateToSettingsGraphRoot() {
// Brings up back to the Settings graph
navigateToSettingsGraph(
navOptions = navOptions {
popUpTo(id = graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
},
)
// Then ensures that we are at the root
popBackStack(route = SETTINGS_ROUTE, inclusive = false)
}
@@ -25,7 +25,9 @@ import com.x8bit.bitwarden.ui.platform.components.model.NavigationItem
import com.x8bit.bitwarden.ui.platform.components.model.ScaffoldNavigationData
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.feature.settings.about.navigateToAbout
import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettingsGraph
import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettingsGraphRoot
import com.x8bit.bitwarden.ui.platform.feature.settings.settingsGraph
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.model.VaultUnlockedNavBarTab
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
@@ -227,6 +229,10 @@ private fun VaultUnlockedNavBarScaffold(
onDimBottomNavBarRequest = { shouldDim -> shouldDimNavBar = shouldDim },
onNavigateToImportLogins = onNavigateToImportLogins,
onNavigateToAddFolderScreen = onNavigateToAddFolderScreen,
onNavigateToAboutScreen = {
navController.navigateToSettingsGraphRoot()
navController.navigateToAbout()
},
)
sendGraph(
navController = navController,
@@ -28,6 +28,7 @@ fun NavGraphBuilder.vaultGraph(
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
onNavigateToAboutScreen: () -> Unit,
) {
navigation(
route = VAULT_GRAPH_ROUTE,
@@ -45,6 +46,7 @@ fun NavGraphBuilder.vaultGraph(
onDimBottomNavBarRequest = onDimBottomNavBarRequest,
onNavigateToImportLogins = onNavigateToImportLogins,
onNavigateToAddFolderScreen = onNavigateToAddFolderScreen,
onNavigateToAboutScreen = onNavigateToAboutScreen,
)
vaultItemListingDestination(
onNavigateBack = { navController.popBackStack() },
@@ -26,6 +26,7 @@ fun NavGraphBuilder.vaultDestination(
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
onNavigateToAboutScreen: () -> Unit,
) {
composableWithRootPushTransitions(
route = VAULT_ROUTE,
@@ -40,6 +41,7 @@ fun NavGraphBuilder.vaultDestination(
onDimBottomNavBarRequest = onDimBottomNavBarRequest,
onNavigateToImportLogins = onNavigateToImportLogins,
onNavigateToAddFolderScreen = onNavigateToAddFolderScreen,
onNavigateToAboutScreen = onNavigateToAboutScreen,
)
}
}
@@ -34,6 +34,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountActionItem
import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountSwitcher
import com.x8bit.bitwarden.ui.platform.components.animation.AnimateNullableContentVisibility
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.appbar.action.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.appbar.action.BitwardenSearchActionItem
@@ -51,6 +52,7 @@ import com.x8bit.bitwarden.ui.platform.components.model.BitwardenPullToRefreshSt
import com.x8bit.bitwarden.ui.platform.components.model.TopAppBarDividerStyle
import com.x8bit.bitwarden.ui.platform.components.model.rememberBitwardenPullToRefreshState
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbar
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHostState
import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState
@@ -94,6 +96,7 @@ fun VaultScreen(
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit,
onNavigateToAboutScreen: () -> Unit,
exitManager: ExitManager = LocalExitManager.current,
intentManager: IntentManager = LocalIntentManager.current,
appReviewManager: AppReviewManager = LocalAppReviewManager.current,
@@ -181,6 +184,8 @@ fun VaultScreen(
VaultEvent.NavigateToAddFolder -> {
onNavigateToAddFolderScreen(null)
}
VaultEvent.NavigateToAbout -> onNavigateToAboutScreen()
}
}
val vaultHandlers = remember(viewModel) { VaultHandlers.create(viewModel) }
@@ -312,9 +317,23 @@ private fun VaultScreenScaffold(
}
},
snackbarHost = {
BitwardenSnackbarHost(
bitwardenHostState = snackbarHostState,
)
AnimateNullableContentVisibility(
targetState = state.flightRecorderSnackBar,
label = "AnimateFlightRecorderSnackbar",
) { data ->
BitwardenSnackbar(
bitwardenSnackbarData = data,
onDismiss = vaultHandlers.dismissFlightRecorderSnackbar,
onActionClick = vaultHandlers.flightRecorderGoToSettingsClick,
)
}
if (state.flightRecorderSnackBar == null) {
// We don't want additional animations from the Animated Visibility and we only
// want this displayed if the flight recorder snackbar is not set.
BitwardenSnackbarHost(
bitwardenHostState = snackbarHostState,
)
}
},
floatingActionButton = {
AnimatedVisibility(
@@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@@ -47,6 +48,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAppBarTitle
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toSnackbarData
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toVaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState
import com.x8bit.bitwarden.ui.vault.feature.vault.util.vaultFilterDataIfRequired
@@ -114,6 +116,9 @@ class VaultViewModel @Inject constructor(
hasMasterPassword = userState.activeAccount.hasMasterPassword,
isRefreshing = false,
showImportActionCard = false,
flightRecorderSnackBar = settingsRepository
.flightRecorderData
.toSnackbarData(clock = clock),
)
},
) {
@@ -162,9 +167,13 @@ class VaultViewModel @Inject constructor(
snackbarRelayManager
.getSnackbarDataFlow(SnackbarRelay.MY_VAULT_RELAY)
.map {
VaultAction.Internal.SnackbarDataReceive(it)
}
.map { VaultAction.Internal.SnackbarDataReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.flightRecorderDataFlow
.map { VaultAction.Internal.FlightRecorderDataReceive(data = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
@@ -205,10 +214,21 @@ class VaultViewModel @Inject constructor(
VaultAction.ImportActionCardClick -> handleImportActionCardClick()
VaultAction.LifecycleResumed -> handleLifecycleResumed()
VaultAction.SelectAddItemType -> handleSelectAddItemType()
VaultAction.DismissFlightRecorderSnackbar -> handleDismissFlightRecorderSnackbar()
VaultAction.FlightRecorderGoToSettingsClick -> handleFlightRecorderGoToSettingsClick()
}
}
//region VaultAction Handlers
private fun handleDismissFlightRecorderSnackbar() {
settingsRepository.dismissFlightRecorderBanner()
}
private fun handleFlightRecorderGoToSettingsClick() {
settingsRepository.dismissFlightRecorderBanner()
sendEvent(VaultEvent.NavigateToAbout)
}
private fun handleSelectAddItemType() {
mutableStateFlow.update {
it.copy(
@@ -607,6 +627,10 @@ class VaultViewModel @Inject constructor(
VaultAction.Internal.InternetConnectionErrorReceived -> {
handleInternetConnectionErrorReceived()
}
is VaultAction.Internal.FlightRecorderDataReceive -> {
handleFlightRecorderDataReceive(action)
}
}
}
@@ -622,6 +646,14 @@ class VaultViewModel @Inject constructor(
}
}
private fun handleFlightRecorderDataReceive(
action: VaultAction.Internal.FlightRecorderDataReceive,
) {
mutableStateFlow.update {
it.copy(flightRecorderSnackBar = action.data.toSnackbarData(clock = clock))
}
}
private fun handleSnackbarDataReceive(action: VaultAction.Internal.SnackbarDataReceive) {
sendEvent(VaultEvent.ShowSnackbar(action.data))
}
@@ -840,6 +872,9 @@ data class VaultState(
val vaultFilterData: VaultFilterData? = null,
val viewState: ViewState,
val dialog: DialogState? = null,
val isRefreshing: Boolean,
val showImportActionCard: Boolean,
val flightRecorderSnackBar: BitwardenSnackbarData?,
// Internal-use properties
val isSwitchingAccounts: Boolean = false,
val isPremium: Boolean,
@@ -847,8 +882,6 @@ data class VaultState(
private val isPullToRefreshSettingEnabled: Boolean,
val baseIconUrl: String,
val isIconLoadingDisabled: Boolean,
val isRefreshing: Boolean,
val showImportActionCard: Boolean,
) : Parcelable {
/**
@@ -1256,12 +1289,27 @@ sealed class VaultEvent {
* Navigate to the add folder screen
*/
data object NavigateToAddFolder : VaultEvent()
/**
* Navigate to settings.
*/
data object NavigateToAbout : VaultEvent()
}
/**
* Models actions for the [VaultScreen].
*/
sealed class VaultAction {
/**
* User has clicked the go to settings button.
*/
data object FlightRecorderGoToSettingsClick : VaultAction()
/**
* User has dismissed the flight recorder.
*/
data object DismissFlightRecorderSnackbar : VaultAction()
/**
* User has triggered a pull to refresh.
*/
@@ -1489,6 +1537,13 @@ sealed class VaultAction {
data class SnackbarDataReceive(
val data: BitwardenSnackbarData,
) : Internal()
/**
* Indicates that the flight recorder data was received.
*/
data class FlightRecorderDataReceive(
val data: FlightRecorderDataSet,
) : Internal()
}
}
@@ -39,6 +39,8 @@ data class VaultHandlers(
val masterPasswordRepromptSubmit: (ListingItemOverflowAction.VaultAction, String) -> Unit,
val dismissImportActionCard: () -> Unit,
val importActionCardClick: () -> Unit,
val flightRecorderGoToSettingsClick: () -> Unit,
val dismissFlightRecorderSnackbar: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
@@ -46,6 +48,7 @@ data class VaultHandlers(
* Creates an instance of [VaultHandlers] by binding actions to the provided
* [VaultViewModel].
*/
@Suppress("LongMethod")
fun create(viewModel: VaultViewModel): VaultHandlers =
VaultHandlers(
vaultFilterTypeSelect = {
@@ -104,6 +107,12 @@ data class VaultHandlers(
importActionCardClick = {
viewModel.trySendAction(VaultAction.ImportActionCardClick)
},
flightRecorderGoToSettingsClick = {
viewModel.trySendAction(VaultAction.FlightRecorderGoToSettingsClick)
},
dismissFlightRecorderSnackbar = {
viewModel.trySendAction(VaultAction.DismissFlightRecorderSnackbar)
},
)
}
}
@@ -0,0 +1,31 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.util
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import java.time.Clock
import java.time.Instant
/**
* Helper function to create a [BitwardenSnackbarData] representing the active flight recorder.
*/
fun FlightRecorderDataSet.toSnackbarData(
clock: Clock,
): BitwardenSnackbarData? {
val expirationTime = this
.data
.find { it.isActive && !it.isBannerDismissed }
?.let { Instant.ofEpochMilli(it.startTimeMs + it.durationMs) }
?: return null
return BitwardenSnackbarData(
message = R.string.flight_recorder_banner_message.asText(
expirationTime.toFormattedPattern(pattern = "M/d/yy", clock = clock),
expirationTime.toFormattedPattern(pattern = "h:mm a", clock = clock),
),
messageHeader = R.string.flight_recorder_banner_title.asText(),
actionLabel = R.string.go_to_settings.asText(),
withDismissAction = true,
)
}
+3
View File
@@ -1253,4 +1253,7 @@ Do you want to switch to this account?</string>
<string name="key_connector_organization">Organization:</string>
<string name="password_no_longer_required_confirm_domain">A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator.</string>
<string name="please_confirm_domain_with_admin">Please confirm the domain below with your organization administrator.\n\nKey Connector domain:\n%1$s</string>
<string name="flight_recorder_banner_title">Flight recorder on</string>
<string name="flight_recorder_banner_message">Flight recorder will be active until %1$s at %2$s. Return to settings to deactivate now.</string>
<string name="go_to_settings">Go to settings</string>
</resources>
@@ -167,6 +167,67 @@ class FlightRecorderManagerTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `dismissFlightRecorderBanner should set the isDismissBanner flag to true and update SettingsDiskSource`() {
val data = FlightRecorderDataSet(
data = setOf(
FlightRecorderDataSet.FlightRecorderData(
id = "40",
fileName = "fileName1",
startTimeMs = FIXED_CLOCK_TIME,
durationMs = 60L,
isActive = true,
expirationTimeMs = null,
isBannerDismissed = false,
),
FlightRecorderDataSet.FlightRecorderData(
id = "50",
fileName = "fileName2",
startTimeMs = FIXED_CLOCK_TIME,
durationMs = 60L,
isActive = false,
expirationTimeMs = FIXED_CLOCK
.instant()
.plus(30, ChronoUnit.DAYS)
.toEpochMilli(),
isBannerDismissed = false,
),
),
)
fakeSettingsDiskSource.flightRecorderData = data
flightRecorder.dismissFlightRecorderBanner()
fakeSettingsDiskSource.assertFlightRecorderData(
expected = FlightRecorderDataSet(
data = setOf(
FlightRecorderDataSet.FlightRecorderData(
id = "40",
fileName = "fileName1",
startTimeMs = FIXED_CLOCK_TIME,
durationMs = 60L,
isActive = true,
expirationTimeMs = null,
isBannerDismissed = true,
),
FlightRecorderDataSet.FlightRecorderData(
id = "50",
fileName = "fileName2",
startTimeMs = FIXED_CLOCK_TIME,
durationMs = 60L,
isActive = false,
expirationTimeMs = FIXED_CLOCK
.instant()
.plus(30, ChronoUnit.DAYS)
.toEpochMilli(),
isBannerDismissed = true,
),
),
),
)
}
@Suppress("MaxLineLength")
@Test
fun `endFlightRecorder should set the active log to inactive and update the SettingsDiskSource`() {
@@ -65,6 +65,7 @@ import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -74,6 +75,7 @@ import org.junit.Test
@Suppress("LargeClass")
class VaultScreenTest : BaseComposeTest() {
private var onNavigateToAboutCalled = false
private var onNavigateToImportLoginsCalled = false
private var onNavigateToVaultAddItemScreenCalled = false
private var onNavigateToVaultItemArgs: VaultItemArgs? = null
@@ -120,6 +122,7 @@ class VaultScreenTest : BaseComposeTest() {
onNavigateToAddFolderCalled = true
onNavigateToAddFolderParentFolderName = folderName
},
onNavigateToAboutScreen = { onNavigateToAboutCalled = true },
)
}
}
@@ -1250,6 +1253,12 @@ class VaultScreenTest : BaseComposeTest() {
assertTrue(onNavigateToImportLoginsCalled)
}
@Test
fun `when NavigateToAbout is sent, it should call onNavigateToAbout`() {
mutableEventFlow.tryEmit(VaultEvent.NavigateToAbout)
assertTrue(onNavigateToAboutCalled)
}
@Test
fun `when ShowSnackbar is sent snackbar should be displayed`() {
val data = BitwardenSnackbarData("message".asText())
@@ -1372,6 +1381,55 @@ class VaultScreenTest : BaseComposeTest() {
)
}
}
@Test
fun `on FlightRecorder Snackbar close click sends the DismissFlightRecorderSnackbar`() =
runTest {
mutableStateFlow.update {
it.copy(
flightRecorderSnackBar = BitwardenSnackbarData(
message = R.string.flight_recorder_banner_message.asText(
"4/12/25",
"9:15 AM",
),
messageHeader = R.string.flight_recorder_banner_title.asText(),
actionLabel = R.string.go_to_settings.asText(),
withDismissAction = true,
),
)
}
composeTestRule.onNodeWithText(text = "Flight recorder on").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(label = "Close").performClick()
verify(exactly = 1) {
viewModel.trySendAction(VaultAction.DismissFlightRecorderSnackbar)
}
}
@Suppress("MaxLineLength")
@Test
fun `on FlightRecorder Snackbar go to setting click sends the FlightRecorderGoToSettingsClick`() =
runTest {
mutableStateFlow.update {
it.copy(
flightRecorderSnackBar = BitwardenSnackbarData(
message = R.string.flight_recorder_banner_message.asText(
"4/12/25",
"9:15 AM",
),
messageHeader = R.string.flight_recorder_banner_title.asText(),
actionLabel = R.string.go_to_settings.asText(),
withDismissAction = true,
),
)
}
composeTestRule.onNodeWithText(text = "Flight recorder on").assertIsDisplayed()
composeTestRule.onNodeWithText(text = "Go to settings").performClick()
verify(exactly = 1) {
viewModel.trySendAction(VaultAction.FlightRecorderGoToSettingsClick)
}
}
}
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
@@ -1426,6 +1484,7 @@ private val DEFAULT_STATE: VaultState = VaultState(
hasMasterPassword = true,
isRefreshing = false,
showImportActionCard = false,
flightRecorderSnackBar = null,
)
private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultState.ViewState.Content(
@@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@@ -47,6 +48,7 @@ import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toSnackbarData
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
@@ -54,7 +56,9 @@ import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -62,6 +66,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
@@ -120,10 +125,15 @@ class VaultViewModelTest : BaseViewModelTest() {
every { switchAccount(any()) } answers { switchAccountResult }
}
private var mutableFlightRecorderDataFlow =
MutableStateFlow(FlightRecorderDataSet(data = emptySet()))
private val settingsRepository: SettingsRepository = mockk {
every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshEnabledFlow
every { isIconLoadingDisabledFlow } returns mutableIsIconLoadingDisabledFlow
every { isIconLoadingDisabled } returns false
every { flightRecorderData } returns FlightRecorderDataSet(data = emptySet())
every { flightRecorderDataFlow } returns mutableFlightRecorderDataFlow
every { dismissFlightRecorderBanner() } just runs
}
private val vaultRepository: VaultRepository =
@@ -157,6 +167,11 @@ class VaultViewModelTest : BaseViewModelTest() {
every { isNetworkConnected } returns true
}
@AfterEach
fun tearDown() {
unmockkStatic(FlightRecorderDataSet::toSnackbarData)
}
@Test
fun `initial state should be correct and should trigger a syncIfNecessary call`() {
val viewModel = createViewModel()
@@ -374,6 +389,52 @@ class VaultViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `Flight Recorder changes should update flightRecorderSnackbar accordingly`() = runTest {
mockkStatic(FlightRecorderDataSet::toSnackbarData)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(flightRecorderSnackBar = null), awaitItem())
val snackbarData = mockk<BitwardenSnackbarData>()
mutableFlightRecorderDataFlow.value = mockk<FlightRecorderDataSet> {
every { toSnackbarData(clock = clock) } returns snackbarData
}
assertEquals(DEFAULT_STATE.copy(flightRecorderSnackBar = snackbarData), awaitItem())
mutableFlightRecorderDataFlow.value = mockk<FlightRecorderDataSet> {
every { toSnackbarData(clock = clock) } returns null
}
assertEquals(DEFAULT_STATE.copy(flightRecorderSnackBar = null), awaitItem())
}
}
@Test
fun `on DismissFlightRecorderSnackbar should call dismissFlightRecorderBanner`() {
val viewModel = createViewModel()
viewModel.trySendAction(VaultAction.DismissFlightRecorderSnackbar)
verify(exactly = 1) {
settingsRepository.dismissFlightRecorderBanner()
}
}
@Test
fun `on FlightRecorderGoToSettingsClick should send NavigateToAbout`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.FlightRecorderGoToSettingsClick)
assertEquals(VaultEvent.NavigateToAbout, awaitItem())
}
verify(exactly = 1) {
settingsRepository.dismissFlightRecorderBanner()
}
}
@Test
fun `on LockAccountClick should call lockVault for the given account`() {
val accountUserId = "userId"
@@ -2119,4 +2180,5 @@ private fun createMockVaultState(
hasMasterPassword = true,
showImportActionCard = true,
isRefreshing = false,
flightRecorderSnackBar = null,
)
@@ -0,0 +1,94 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.util
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class FlightRecorderDataSetExtensionsTest {
@Test
fun `toSnackbarData with empty data should return null`() {
val dataset = FlightRecorderDataSet(data = emptySet())
val result = dataset.toSnackbarData(clock = FIXED_CLOCK)
assertNull(result)
}
@Test
fun `toSnackbarData with no active logs should return null`() {
val dataset = FlightRecorderDataSet(
data = setOf(
DEFAULT_DATA.copy(
isActive = false,
isBannerDismissed = false,
),
),
)
val result = dataset.toSnackbarData(clock = FIXED_CLOCK)
assertNull(result)
}
@Test
fun `toSnackbarData with active logs but dismissed banner should return null`() {
val dataset = FlightRecorderDataSet(
data = setOf(
DEFAULT_DATA.copy(
isActive = true,
isBannerDismissed = true,
),
),
)
val result = dataset.toSnackbarData(clock = FIXED_CLOCK)
assertNull(result)
}
@Test
fun `toSnackbarData with active logs and un-dismissed banner should return SnackbarData`() {
val dataset = FlightRecorderDataSet(
data = setOf(
DEFAULT_DATA.copy(
isActive = true,
isBannerDismissed = false,
),
),
)
val result = dataset.toSnackbarData(clock = FIXED_CLOCK)
assertEquals(
BitwardenSnackbarData(
message = R.string.flight_recorder_banner_message.asText("4/12/25", "9:15 AM"),
messageHeader = R.string.flight_recorder_banner_title.asText(),
actionLabel = R.string.go_to_settings.asText(),
withDismissAction = true,
),
result,
)
}
}
private val DEFAULT_DATA = FlightRecorderDataSet.FlightRecorderData(
id = "50",
fileName = "flight_recorder",
startTimeMs = 1_744_445_752_855L,
durationMs = 3_600_000L,
expirationTimeMs = null,
isActive = false,
isBannerDismissed = false,
)
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)