PM-19978: Build out flight recorder UI (#5009)

This commit is contained in:
David Perez
2025-04-08 14:11:05 -05:00
committed by GitHub
parent c540d3ef47
commit 6ebece1b1e
8 changed files with 374 additions and 7 deletions

View File

@@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.platform.repository.model
/**
* The selectable durations allowed for the flight recorder.
*/
enum class FlightRecorderDuration(
val milliseconds: Long,
) {
ONE_HOUR(milliseconds = 3_600_000L),
EIGHT_HOURS(milliseconds = 28_800_000L),
TWENTY_FOUR_HOURS(milliseconds = 86_400_000L),
ONE_WEEK(milliseconds = 604_800_000L),
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.util.spanStyleOf
/**
* Uses an annotated string resource to create a string with clickable text.
@@ -30,7 +31,10 @@ fun BitwardenHyperTextLink(
color: Color = BitwardenTheme.colorScheme.text.secondary,
) {
Text(
text = annotatedResId.toAnnotatedString(args = args) { key ->
text = annotatedResId.toAnnotatedString(
args = args,
style = spanStyleOf(color = color, textStyle = style),
) { key ->
when (key) {
annotationKey -> onClick()
}

View File

@@ -1,19 +1,45 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
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.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
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.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenHyperTextLink
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.util.displayText
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import kotlinx.collections.immutable.toImmutableList
/**
* Displays the flight recorder configuration screen.
@@ -23,10 +49,15 @@ import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
fun FlightRecorderScreen(
onNavigateBack: () -> Unit,
viewModel: FlightRecorderViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel) { event ->
when (event) {
FlightRecorderEvent.NavigateBack -> onNavigateBack()
FlightRecorderEvent.NavigateToHelpCenter -> {
intentManager.launchUri(uri = "https://bitwarden.com/help".toUri())
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@@ -40,10 +71,130 @@ fun FlightRecorderScreen(
{ viewModel.trySendAction(FlightRecorderAction.BackClick) }
},
scrollBehavior = scrollBehavior,
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.save),
onClick = remember(viewModel) {
{ viewModel.trySendAction(FlightRecorderAction.SaveClick) }
},
modifier = Modifier.testTag("SaveButton"),
)
},
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) {
// TODO: PM-19592 Create the flight recorder UI.
FlightRecorderContent(
state = state,
onDurationSelected = remember(viewModel) {
{ viewModel.trySendAction(FlightRecorderAction.DurationSelect(it)) }
},
onHelpCenterClick = remember(viewModel) {
{ viewModel.trySendAction(FlightRecorderAction.HelpCenterClick) }
},
)
}
}
@Suppress("LongMethod")
@Composable
private fun FlightRecorderContent(
state: FlightRecorderState,
onDurationSelected: (FlightRecorderDuration) -> Unit,
onHelpCenterClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(state = rememberScrollState()),
) {
Spacer(modifier = Modifier.height(height = 24.dp))
Text(
text = stringResource(id = R.string.experiencing_an_issue),
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
Text(
text = stringResource(
id = R.string.enable_temporary_logging_to_collect_and_inspect_logs_locally,
),
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
Text(
text = stringResource(id = R.string.to_get_started_set_a_logging_duration),
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
DurationSelectButton(
selectedOption = state.selectedDuration,
onOptionSelected = onDurationSelected,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
Text(
text = stringResource(id = R.string.logs_will_be_automatically_deleted_after_30_days),
color = BitwardenTheme.colorScheme.text.secondary,
style = BitwardenTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenHyperTextLink(
annotatedResId = R.string.for_details_on_what_is_and_isnt_logged,
annotationKey = "helpCenter",
accessibilityString = stringResource(id = R.string.bitwarden_help_center),
onClick = onHelpCenterClick,
color = BitwardenTheme.colorScheme.text.secondary,
style = BitwardenTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun DurationSelectButton(
selectedOption: FlightRecorderDuration,
onOptionSelected: (FlightRecorderDuration) -> Unit,
modifier: Modifier = Modifier,
) {
val resources = LocalContext.current.resources
val options = FlightRecorderDuration.entries.map { it.displayText() }.toImmutableList()
BitwardenMultiSelectButton(
label = stringResource(id = R.string.logging_duration),
options = options,
selectedOption = selectedOption.displayText(),
onOptionSelected = { selectedOption ->
onOptionSelected(
FlightRecorderDuration
.entries
.first { selectedOption == it.displayText.toString(resources) },
)
},
cardStyle = CardStyle.Full,
modifier = modifier,
)
}

View File

@@ -1,8 +1,10 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder
import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import javax.inject.Inject
private const val KEY_STATE = "state"
@@ -15,23 +17,43 @@ 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,
initialState = savedStateHandle[KEY_STATE]
?: FlightRecorderState(
selectedDuration = FlightRecorderDuration.ONE_HOUR,
),
) {
override fun handleAction(action: FlightRecorderAction) {
when (action) {
FlightRecorderAction.BackClick -> handleBackClick()
is FlightRecorderAction.DurationSelect -> handleOnDurationSelect(action)
FlightRecorderAction.HelpCenterClick -> handleHelpCenterClick()
FlightRecorderAction.SaveClick -> handleSaveClick()
}
}
private fun handleBackClick() {
sendEvent(FlightRecorderEvent.NavigateBack)
}
private fun handleOnDurationSelect(action: FlightRecorderAction.DurationSelect) {
mutableStateFlow.update { it.copy(selectedDuration = action.duration) }
}
private fun handleHelpCenterClick() {
sendEvent(FlightRecorderEvent.NavigateToHelpCenter)
}
private fun handleSaveClick() {
// TODO: PM-19592 Persist the flight recorder state.
}
}
/**
* Models the UI state for the flight recorder screen.
*/
data object FlightRecorderState
data class FlightRecorderState(
val selectedDuration: FlightRecorderDuration,
)
/**
* Models events for the flight recorder screen.
@@ -41,6 +63,11 @@ sealed class FlightRecorderEvent {
* Navigates back.
*/
data object NavigateBack : FlightRecorderEvent()
/**
* Launches the the help center link.
*/
data object NavigateToHelpCenter : FlightRecorderEvent()
}
/**
@@ -51,4 +78,21 @@ sealed class FlightRecorderAction {
* Indicates that the user clicked the close button.
*/
data object BackClick : FlightRecorderAction()
/**
* Indicates that the user clicked the help center link.
*/
data object HelpCenterClick : FlightRecorderAction()
/**
* Indicates that the user clicked the save button.
*/
data object SaveClick : FlightRecorderAction()
/**
* Indicates that the user selected a duration.
*/
data class DurationSelect(
val duration: FlightRecorderDuration,
) : FlightRecorderAction()
}

View File

@@ -0,0 +1,20 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.util
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
/**
* A helper function to map the [FlightRecorderDuration] to a displayable label.
*/
val FlightRecorderDuration.displayText: Text
get() = when (this) {
FlightRecorderDuration.ONE_HOUR -> R.string.flight_recorder_one_hour.asText()
FlightRecorderDuration.EIGHT_HOURS -> R.string.flight_recorder_eight_hours.asText()
FlightRecorderDuration.TWENTY_FOUR_HOURS -> {
R.string.flight_recorder_twenty_four_hours.asText()
}
FlightRecorderDuration.ONE_WEEK -> R.string.flight_recorder_one_week.asText()
}

View File

@@ -1226,4 +1226,14 @@ Do you want to switch to this account?</string>
<string name="enable_flight_recorder_title">Enable flight recorder</string>
<string name="recorded_logs_title">Recorded logs</string>
<string name="no_logs_recorded">No logs recorded</string>
<string name="experiencing_an_issue">Experiencing an issue?</string>
<string name="enable_temporary_logging_to_collect_and_inspect_logs_locally">Enable temporary logging to collect and inspect logs locally. When enabled, application states and network calls may be logged—never sensitive vault information.</string>
<string name="to_get_started_set_a_logging_duration">To get started, set a logging duration. Logging will automatically turn off after this period, giving you time to capture activity while reproducing any issues.</string>
<string name="logs_will_be_automatically_deleted_after_30_days">Logs will be automatically deleted after 30 days. Bitwarden is only able to access your log data when you share it.</string>
<string name="for_details_on_what_is_and_isnt_logged">For details on what is and isnt logged, visit the\n<annotation link="helpCenter">Bitwarden help center.</annotation></string>
<string name="logging_duration">Logging duration</string>
<string name="flight_recorder_one_hour">1 hour</string>
<string name="flight_recorder_eight_hours">8 hours</string>
<string name="flight_recorder_twenty_four_hours">24 hours</string>
<string name="flight_recorder_one_week">1 week</string>
</resources>

View File

@@ -1,11 +1,24 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.core.net.toUri
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.performCustomAccessibilityAction
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
@@ -22,9 +35,15 @@ class FlightRecorderScreenTest : BaseComposeTest() {
every { stateFlow } returns mutableStateFlow
}
private val intentManager = mockk<IntentManager> {
every { launchUri(uri = any()) } just runs
}
@Before
fun setUp() {
setContent {
setContent(
intentManager = intentManager,
) {
FlightRecorderScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
@@ -42,11 +61,72 @@ class FlightRecorderScreenTest : BaseComposeTest() {
}
}
@Test
fun `on save click should emit SaveClick action`() {
composeTestRule.onNodeWithText(text = "Save").performClick()
verify(exactly = 1) {
viewModel.trySendAction(FlightRecorderAction.SaveClick)
}
}
@Test
fun `on help center click should emit HelpCenterClick action`() {
composeTestRule
.onNodeWithText(text = "Bitwarden help center", substring = true)
.performScrollTo()
.performCustomAccessibilityAction(label = "Bitwarden help center")
verify(exactly = 1) {
viewModel.trySendAction(FlightRecorderAction.HelpCenterClick)
}
}
@Test
fun `on logging duration click should display select dialog`() {
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithContentDescription(label = "1 hour. Logging duration")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText(text = "Logging duration")
.assert(hasAnyAncestor(isDialog()))
.isDisplayed()
}
@Test
fun `on selection of new duration should emit action`() {
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithContentDescription(label = "1 hour. Logging duration")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText(text = "24 hours")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
FlightRecorderAction.DurationSelect(FlightRecorderDuration.TWENTY_FOUR_HOURS),
)
}
}
@Test
fun `on NavigateBack event should invoke onNavigateBack`() {
mutableEventFlow.tryEmit(FlightRecorderEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `on NavigateToHelpCenter event should launch intent for help center`() {
mutableEventFlow.tryEmit(FlightRecorderEvent.NavigateToHelpCenter)
verify(exactly = 1) {
intentManager.launchUri(uri = "https://bitwarden.com/help".toUri())
}
}
}
private val DEFAULT_STATE: FlightRecorderState = FlightRecorderState
private val DEFAULT_STATE: FlightRecorderState =
FlightRecorderState(
selectedDuration = FlightRecorderDuration.ONE_HOUR,
)

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
@@ -24,6 +25,47 @@ class FlightRecorderViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on SaveClick action should do nothing`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(FlightRecorderAction.SaveClick)
expectNoEvents()
}
}
@Test
fun `on HelpCenterClick action should send the NavigateToHelpCenter event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(FlightRecorderAction.HelpCenterClick)
assertEquals(FlightRecorderEvent.NavigateToHelpCenter, awaitItem())
}
}
@Test
fun `on DurationSelect action should update the selectedDuration state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(
FlightRecorderAction.DurationSelect(duration = FlightRecorderDuration.ONE_WEEK),
)
assertEquals(
DEFAULT_STATE.copy(selectedDuration = FlightRecorderDuration.ONE_WEEK),
awaitItem(),
)
viewModel.trySendAction(
FlightRecorderAction.DurationSelect(duration = FlightRecorderDuration.EIGHT_HOURS),
)
assertEquals(
DEFAULT_STATE.copy(selectedDuration = FlightRecorderDuration.EIGHT_HOURS),
awaitItem(),
)
}
}
private fun createViewModel(
state: FlightRecorderState? = null,
): FlightRecorderViewModel =
@@ -34,4 +76,7 @@ class FlightRecorderViewModelTest : BaseViewModelTest() {
)
}
private val DEFAULT_STATE: FlightRecorderState = FlightRecorderState
private val DEFAULT_STATE: FlightRecorderState =
FlightRecorderState(
selectedDuration = FlightRecorderDuration.ONE_HOUR,
)