mirror of
https://github.com/bitwarden/android.git
synced 2026-08-29 10:17:56 -05:00
PM-19594: Add flight recorder banner (#5079)
This commit is contained in:
+3
@@ -43,6 +43,9 @@ data class FlightRecorderDataSet(
|
||||
@SerialName("isActive")
|
||||
val isActive: Boolean,
|
||||
|
||||
@SerialName("isBannerDismissed")
|
||||
val isBannerDismissed: Boolean = false,
|
||||
|
||||
@SerialName("expirationTime")
|
||||
val expirationTimeMs: Long? = null,
|
||||
)
|
||||
|
||||
+5
@@ -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].
|
||||
*/
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+50
@@ -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,
|
||||
)
|
||||
}
|
||||
+7
-2
@@ -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,
|
||||
|
||||
+31
-16
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -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
|
||||
|
||||
+5
-2
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+21
-1
@@ -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)
|
||||
}
|
||||
|
||||
+6
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+61
@@ -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,
|
||||
)
|
||||
|
||||
+94
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user