PM-12760 Add way to re-show the onboarding carousel via debug menu (#3999)

This commit is contained in:
Dave Severns 2024-10-03 09:58:37 -04:00 committed by GitHub
parent e2e5042be5
commit 569ffc3583
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 170 additions and 13 deletions

View file

@ -37,4 +37,11 @@ interface DebugMenuRepository {
* Resets the onboarding status to NOT_STARTED for the current active user, if applicable. * Resets the onboarding status to NOT_STARTED for the current active user, if applicable.
*/ */
fun resetOnboardingStatusForCurrentUser() fun resetOnboardingStatusForCurrentUser()
/**
* Manipulates the state to force showing the onboarding carousel.
*
* @param userStateUpdateTrigger A passable lambda to trigger a user state update.
*/
fun modifyStateToShowOnboardingCarousel(userStateUpdateTrigger: () -> Unit)
} }

View file

@ -4,6 +4,7 @@ import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.getFlagValueOrDefault import com.x8bit.bitwarden.data.platform.manager.getFlagValueOrDefault
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.onSubscription
class DebugMenuRepositoryImpl( class DebugMenuRepositoryImpl(
private val featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource, private val featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource,
private val serverConfigRepository: ServerConfigRepository, private val serverConfigRepository: ServerConfigRepository,
private val settingsDiskSource: SettingsDiskSource,
private val authDiskSource: AuthDiskSource, private val authDiskSource: AuthDiskSource,
) : DebugMenuRepository { ) : DebugMenuRepository {
@ -53,4 +55,11 @@ class DebugMenuRepositoryImpl(
onboardingStatus = OnboardingStatus.NOT_STARTED, onboardingStatus = OnboardingStatus.NOT_STARTED,
) )
} }
override fun modifyStateToShowOnboardingCarousel(
userStateUpdateTrigger: () -> Unit,
) {
settingsDiskSource.hasUserLoggedInOrCreatedAccount = false
userStateUpdateTrigger.invoke()
}
} }

View file

@ -117,9 +117,11 @@ object PlatformRepositoryModule {
featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource, featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource,
serverConfigRepository: ServerConfigRepository, serverConfigRepository: ServerConfigRepository,
authDiskSource: AuthDiskSource, authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
): DebugMenuRepository = DebugMenuRepositoryImpl( ): DebugMenuRepository = DebugMenuRepositoryImpl(
featureFlagOverrideDiskSource = featureFlagOverrideDiskSource, featureFlagOverrideDiskSource = featureFlagOverrideDiskSource,
serverConfigRepository = serverConfigRepository, serverConfigRepository = serverConfigRepository,
authDiskSource = authDiskSource, authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
) )
} }

View file

@ -42,6 +42,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
* Top level screen for the debug menu. * Top level screen for the debug menu.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable @Composable
fun DebugMenuScreen( fun DebugMenuScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
@ -95,11 +96,21 @@ fun DebugMenuScreen(
}, },
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// Pulled these into variable to avoid over-nested formatting in the composable call.
val isRestartOnboardingEnabled = state.featureFlags[FlagKey.OnboardingFlow] as? Boolean
val isRestartOnboardingCarouselEnabled = state
.featureFlags[FlagKey.OnboardingCarousel] as? Boolean
OnboardingOverrideContent( OnboardingOverrideContent(
enabled = (state.featureFlags[FlagKey.OnboardingFlow] as? Boolean) == true, isRestartOnboardingEnabled = isRestartOnboardingEnabled == true,
onStartOnboarding = remember(viewModel) { onStartOnboarding = remember(viewModel) {
{ {
viewModel.trySendAction(DebugMenuAction.ReStartOnboarding) viewModel.trySendAction(DebugMenuAction.RestartOnboarding)
}
},
isCarouselOverrideEnabled = isRestartOnboardingCarouselEnabled == true,
onStartOnboardingCarousel = remember(viewModel) {
{
viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel)
} }
}, },
) )
@ -144,10 +155,15 @@ private fun FeatureFlagContent(
} }
} }
/**
* The content for the onboarding override feature flag.
*/
@Composable @Composable
private fun OnboardingOverrideContent( private fun OnboardingOverrideContent(
enabled: Boolean, isRestartOnboardingEnabled: Boolean,
onStartOnboarding: () -> Unit, onStartOnboarding: () -> Unit,
isCarouselOverrideEnabled: Boolean,
onStartOnboardingCarousel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier) { Column(modifier) {
@ -161,7 +177,7 @@ private fun OnboardingOverrideContent(
BitwardenFilledButton( BitwardenFilledButton(
label = stringResource(R.string.restart_onboarding_cta), label = stringResource(R.string.restart_onboarding_cta),
onClick = onStartOnboarding, onClick = onStartOnboarding,
isEnabled = enabled, isEnabled = isRestartOnboardingEnabled,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.standardHorizontalMargin(), .standardHorizontalMargin(),
@ -176,6 +192,25 @@ private fun OnboardingOverrideContent(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(16.dp))
BitwardenFilledButton(
label = stringResource(R.string.restart_onboarding_carousel),
onClick = onStartOnboardingCarousel,
isEnabled = isCarouselOverrideEnabled,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.restart_onboarding_carousel_details),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.standardHorizontalMargin(),
style = BitwardenTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
} }
} }
@ -199,6 +234,11 @@ private fun FeatureFlagContent_preview() {
@Composable @Composable
private fun OnboardingOverrideContent_preview() { private fun OnboardingOverrideContent_preview() {
BitwardenTheme { BitwardenTheme {
OnboardingOverrideContent(onStartOnboarding = {}, enabled = true) OnboardingOverrideContent(
onStartOnboarding = {},
isRestartOnboardingEnabled = true,
onStartOnboardingCarousel = {},
isCarouselOverrideEnabled = true,
)
} }
} }

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
@ -22,6 +23,7 @@ import javax.inject.Inject
class DebugMenuViewModel @Inject constructor( class DebugMenuViewModel @Inject constructor(
featureFlagManager: FeatureFlagManager, featureFlagManager: FeatureFlagManager,
private val debugMenuRepository: DebugMenuRepository, private val debugMenuRepository: DebugMenuRepository,
private val authRepository: AuthRepository,
) : BaseViewModel<DebugMenuState, DebugMenuEvent, DebugMenuAction>( ) : BaseViewModel<DebugMenuState, DebugMenuEvent, DebugMenuAction>(
initialState = DebugMenuState(featureFlags = emptyMap()), initialState = DebugMenuState(featureFlags = emptyMap()),
) { ) {
@ -44,10 +46,19 @@ class DebugMenuViewModel @Inject constructor(
is DebugMenuAction.Internal.UpdateFeatureFlagMap -> handleUpdateFeatureFlagMap(action) is DebugMenuAction.Internal.UpdateFeatureFlagMap -> handleUpdateFeatureFlagMap(action)
DebugMenuAction.NavigateBack -> handleNavigateBack() DebugMenuAction.NavigateBack -> handleNavigateBack()
DebugMenuAction.ResetFeatureFlagValues -> handleResetFeatureFlagValues() DebugMenuAction.ResetFeatureFlagValues -> handleResetFeatureFlagValues()
DebugMenuAction.ReStartOnboarding -> handleResetOnboardingStatus() DebugMenuAction.RestartOnboarding -> handleResetOnboardingStatus()
DebugMenuAction.RestartOnboardingCarousel -> handleResetOnboardingCarousel()
} }
} }
private fun handleResetOnboardingCarousel() {
debugMenuRepository.modifyStateToShowOnboardingCarousel(
userStateUpdateTrigger = {
authRepository.hasPendingAccountAddition = true
},
)
}
private fun handleResetOnboardingStatus() { private fun handleResetOnboardingStatus() {
debugMenuRepository.resetOnboardingStatusForCurrentUser() debugMenuRepository.resetOnboardingStatusForCurrentUser()
} }
@ -115,7 +126,12 @@ sealed class DebugMenuAction {
/** /**
* The user has clicked the restart onboarding button for the onboarding section. * The user has clicked the restart onboarding button for the onboarding section.
*/ */
data object ReStartOnboarding : DebugMenuAction() data object RestartOnboarding : DebugMenuAction()
/**
* The user has clicked the restart onboarding button for the onboarding section.
*/
data object RestartOnboardingCarousel : DebugMenuAction()
/** /**
* Internal actions not triggered from the UI. * Internal actions not triggered from the UI.

View file

@ -17,5 +17,7 @@
<string name="onboarding_override">Onboarding Status Override</string> <string name="onboarding_override">Onboarding Status Override</string>
<string name="restart_onboarding_cta">Restart Onboarding</string> <string name="restart_onboarding_cta">Restart Onboarding</string>
<string name="restart_onboarding_details">This will reset the onboarding status for the current user, if available. After clicking the button you will immediately be redirected to the onboarding flow. Onboarding flag must be enabled.</string> <string name="restart_onboarding_details">This will reset the onboarding status for the current user, if available. After clicking the button you will immediately be redirected to the onboarding flow. Onboarding flag must be enabled.</string>
<string name="restart_onboarding_carousel">Show Onboarding Carousel</string>
<string name="restart_onboarding_carousel_details">This will force the change to app state which will cause the first time carousel to show. The carousel will continue to show for any \"new\" account until a login is completed. May need to exit debug menu manually.</string>
<!-- /Debug Menu --> <!-- /Debug Menu -->
</resources> </resources>

View file

@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
@ -39,9 +40,15 @@ class DebugMenuRepositoryTest {
every { storeOnboardingStatus(any(), any()) } just runs every { storeOnboardingStatus(any(), any()) } just runs
} }
private val mockSettingsDiskSource = mockk<SettingsDiskSource>(relaxed = true) {
every { hasUserLoggedInOrCreatedAccount } returns true
every { hasUserLoggedInOrCreatedAccount = any() } just runs
}
private val debugMenuRepository = DebugMenuRepositoryImpl( private val debugMenuRepository = DebugMenuRepositoryImpl(
featureFlagOverrideDiskSource = mockFeatureFlagOverrideDiskSource, featureFlagOverrideDiskSource = mockFeatureFlagOverrideDiskSource,
serverConfigRepository = mockServerConfigRepository, serverConfigRepository = mockServerConfigRepository,
settingsDiskSource = mockSettingsDiskSource,
authDiskSource = mockAuthDiskSource, authDiskSource = mockAuthDiskSource,
) )
@ -182,6 +189,20 @@ class DebugMenuRepositoryTest {
) )
} }
} }
@Suppress("MaxLineLength")
@Test
fun `modifyStateToShowOnboardingCarousel should set hasUserLoggedInOrCreatedAccount to false and trigger user state update`() {
var lambdaHasBeenCalled = false
val triggerUserStateUpdate = {
lambdaHasBeenCalled = true
}
debugMenuRepository.modifyStateToShowOnboardingCarousel(triggerUserStateUpdate)
verify {
mockSettingsDiskSource.hasUserLoggedInOrCreatedAccount = false
}
assertTrue(lambdaHasBeenCalled)
}
} }
private const val TEST_STRING_VALUE = "test" private const val TEST_STRING_VALUE = "test"

View file

@ -110,7 +110,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
} }
@Test @Test
fun `restart onboarding should send action when clicked`() { fun `restart onboarding should send action when enabled and clicked`() {
mutableStateFlow.tryEmit( mutableStateFlow.tryEmit(
DebugMenuState( DebugMenuState(
featureFlags = mapOf( featureFlags = mapOf(
@ -124,11 +124,11 @@ class DebugMenuScreenTest : BaseComposeTest() {
.assertIsEnabled() .assertIsEnabled()
.performClick() .performClick()
verify { viewModel.trySendAction(DebugMenuAction.ReStartOnboarding) } verify { viewModel.trySendAction(DebugMenuAction.RestartOnboarding) }
} }
@Test @Test
fun `no restart onboarding should not send action when not enabled`() { fun `restart onboarding should not send action when not enabled`() {
mutableStateFlow.tryEmit( mutableStateFlow.tryEmit(
DebugMenuState( DebugMenuState(
featureFlags = mapOf( featureFlags = mapOf(
@ -143,6 +143,43 @@ class DebugMenuScreenTest : BaseComposeTest() {
.assertIsNotEnabled() .assertIsNotEnabled()
.performClick() .performClick()
verify(exactly = 0) { viewModel.trySendAction(DebugMenuAction.ReStartOnboarding) } verify(exactly = 0) { viewModel.trySendAction(DebugMenuAction.RestartOnboarding) }
}
@Test
fun `Show onboarding carousel should send action when enabled and clicked`() {
mutableStateFlow.tryEmit(
DebugMenuState(
featureFlags = mapOf(
FlagKey.OnboardingCarousel to true,
),
),
)
composeTestRule
.onNodeWithText("Show Onboarding Carousel", ignoreCase = true)
.performScrollTo()
.assertIsEnabled()
.performClick()
verify { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) }
}
@Test
fun `show onboarding carousel should not send action when not enabled`() {
mutableStateFlow.tryEmit(
DebugMenuState(
featureFlags = mapOf(
FlagKey.OnboardingCarousel to false,
),
),
)
composeTestRule
.onNodeWithText("Show Onboarding Carousel", ignoreCase = true)
.performScrollTo()
.assertIsNotEnabled()
.performClick()
verify(exactly = 0) { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) }
} }
} }

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
@ -23,10 +24,20 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
every { getFeatureFlagFlow<Boolean>(any()) } returns flowOf(true) every { getFeatureFlagFlow<Boolean>(any()) } returns flowOf(true)
} }
private val mockDebugMenuRepository = mockk<DebugMenuRepository> { private val mockAuthRepository = mockk<AuthRepository>(relaxed = true) {
every { hasPendingAccountAddition = true } just runs
}
private val mockDebugMenuRepository = mockk<DebugMenuRepository>(relaxed = true) {
coEvery { resetFeatureFlagOverrides() } just runs coEvery { resetFeatureFlagOverrides() } just runs
every { updateFeatureFlag<Boolean>(any(), any()) } just runs every { updateFeatureFlag<Boolean>(any(), any()) } just runs
every { resetOnboardingStatusForCurrentUser() } just runs every { resetOnboardingStatusForCurrentUser() } just runs
every {
modifyStateToShowOnboardingCarousel(userStateUpdateTrigger = any())
} answers {
// invokes the passed in lambda, allowing verification in tests.
firstArg<() -> Unit>().invoke()
}
} }
@Test @Test
@ -73,13 +84,25 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
@Test @Test
fun `handleResetOnboardingStatus should reset the onboarding status`() { fun `handleResetOnboardingStatus should reset the onboarding status`() {
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.ReStartOnboarding) viewModel.trySendAction(DebugMenuAction.RestartOnboarding)
verify { mockDebugMenuRepository.resetOnboardingStatusForCurrentUser() } verify { mockDebugMenuRepository.resetOnboardingStatusForCurrentUser() }
} }
@Suppress("MaxLineLength")
@Test
fun `handleResetOnboardingCarousel should reset the onboarding carousel and update user state pending account action`() {
val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel)
verify {
mockDebugMenuRepository.modifyStateToShowOnboardingCarousel(any())
mockAuthRepository.hasPendingAccountAddition = true
}
}
private fun createViewModel(): DebugMenuViewModel = DebugMenuViewModel( private fun createViewModel(): DebugMenuViewModel = DebugMenuViewModel(
featureFlagManager = mockFeatureFlagManager, featureFlagManager = mockFeatureFlagManager,
debugMenuRepository = mockDebugMenuRepository, debugMenuRepository = mockDebugMenuRepository,
authRepository = mockAuthRepository,
) )
} }