Skip to content

Commit

Permalink
PM-11174 Action card for import logins flow (#4057)
Browse files Browse the repository at this point in the history
  • Loading branch information
dseverns-livefront authored Oct 11, 2024
1 parent 028242c commit ba8e3a6
Show file tree
Hide file tree
Showing 50 changed files with 856 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -306,4 +306,19 @@ interface AuthDiskSource {
* if any exists.
*/
fun getOnboardingStatusFlow(userId: String): Flow<OnboardingStatus?>

/**
* Gets the show import logins flag for the given [userId].
*/
fun getShowImportLogins(userId: String): Boolean?

/**
* Stores the show import logins flag for the given [userId].
*/
fun storeShowImportLogins(userId: String, showImportLogins: Boolean?)

/**
* Emits updates that track [getShowImportLogins]. This will replay the last known value,
*/
fun getShowImportLoginsFlow(userId: String): Flow<Boolean?>
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ private const val SHOULD_TRUST_DEVICE_KEY = "shouldTrustDevice"
private const val TDE_LOGIN_COMPLETE = "tdeLoginComplete"
private const val USES_KEY_CONNECTOR = "usesKeyConnector"
private const val ONBOARDING_STATUS_KEY = "onboardingStatus"
private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins"

/**
* Primary implementation of [AuthDiskSource].
Expand Down Expand Up @@ -72,6 +73,7 @@ class AuthDiskSourceImpl(
mutableMapOf<String, MutableSharedFlow<AccountTokensJson?>>()
private val mutableOnboardingStatusFlowMap =
mutableMapOf<String, MutableSharedFlow<OnboardingStatus?>>()
private val mutableShowImportLoginsFlowMap = mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableUserStateFlow = bufferedMutableSharedFlow<UserStateJson?>(replay = 1)

override var userState: UserStateJson?
Expand Down Expand Up @@ -143,9 +145,11 @@ class AuthDiskSourceImpl(
storeShouldUseKeyConnector(userId = userId, shouldUseKeyConnector = null)
storeIsTdeLoginComplete(userId = userId, isTdeLoginComplete = null)
storeAuthenticatorSyncUnlockKey(userId = userId, authenticatorSyncUnlockKey = null)
storeShowImportLogins(userId = userId, showImportLogins = null)

// Do not remove the DeviceKey or PendingAuthRequest on logout, these are persisted
// indefinitely unless the TDE flow explicitly removes them.
// Do not remove OnboardingStatus we want to keep track of this even after logout.
}

override fun getAuthenticatorSyncUnlockKey(userId: String): String? =
Expand Down Expand Up @@ -437,6 +441,22 @@ class AuthDiskSourceImpl(
.onSubscription { emit(getOnboardingStatus(userId = userId)) }
}

override fun getShowImportLogins(userId: String): Boolean? {
return getBoolean(SHOW_IMPORT_LOGINS_KEY.appendIdentifier(userId))
}

override fun storeShowImportLogins(userId: String, showImportLogins: Boolean?) {
putBoolean(
key = SHOW_IMPORT_LOGINS_KEY.appendIdentifier(userId),
value = showImportLogins,
)
getMutableShowImportLoginsFlow(userId = userId).tryEmit(showImportLogins)
}

override fun getShowImportLoginsFlow(userId: String): Flow<Boolean?> =
getMutableShowImportLoginsFlow(userId)
.onSubscription { emit(getShowImportLogins(userId)) }

private fun generateAndStoreUniqueAppId(): String =
UUID
.randomUUID()
Expand Down Expand Up @@ -480,6 +500,12 @@ class AuthDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}

private fun getMutableShowImportLoginsFlow(
userId: String,
): MutableSharedFlow<Boolean?> = mutableShowImportLoginsFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}

private fun migrateAccountTokens() {
userState
?.accounts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,4 +393,9 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
* Update the value of the onboarding status for the user.
*/
fun setOnboardingStatus(userId: String, status: OnboardingStatus?)

/**
* Update the value of the showImportLogins status for the user.
*/
fun setShowImportLogins(showImportLogins: Boolean)
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.currentOnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.util.currentOrDefaultUserFirstTimeState
import com.x8bit.bitwarden.data.auth.repository.util.firstTimeStateFlow
import com.x8bit.bitwarden.data.auth.repository.util.onboardingStatusChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
Expand Down Expand Up @@ -254,6 +256,7 @@ class AuthRepositoryImpl(
authDiskSource.userOrganizationsListFlow,
authDiskSource.userKeyConnectorStateFlow,
authDiskSource.onboardingStatusChangesFlow,
authDiskSource.firstTimeStateFlow,
vaultRepository.vaultUnlockDataStateFlow,
mutableHasPendingAccountAdditionStateFlow,
// Ignore the data in the merge, but trigger an update when they emit.
Expand All @@ -267,8 +270,9 @@ class AuthRepositoryImpl(
val userOrganizationsList = array[2] as List<UserOrganizations>
val userIsUsingKeyConnectorList = array[3] as List<UserKeyConnectorState>
val onboardingStatus = array[4] as OnboardingStatus?
val vaultState = array[5] as List<VaultUnlockData>
val hasPendingAccountAddition = array[6] as Boolean
val firstTimeState = array[5] as UserState.FirstTimeState
val vaultState = array[6] as List<VaultUnlockData>
val hasPendingAccountAddition = array[7] as Boolean
userStateJson?.toUserState(
vaultState = vaultState,
userAccountTokens = userAccountTokens,
Expand All @@ -279,6 +283,7 @@ class AuthRepositoryImpl(
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
firstTimeState = firstTimeState,
)
}
.filterNot { mutableHasPendingAccountDeletionStateFlow.value }
Expand All @@ -298,6 +303,7 @@ class AuthRepositoryImpl(
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
firstTimeState = authDiskSource.currentOrDefaultUserFirstTimeState,
),
)

Expand Down Expand Up @@ -1297,6 +1303,11 @@ class AuthRepositoryImpl(
authDiskSource.storeOnboardingStatus(userId = userId, onboardingStatus = status)
}

override fun setShowImportLogins(showImportLogins: Boolean) {
val userId: String = activeUserId ?: return
authDiskSource.storeShowImportLogins(userId = userId, showImportLogins = showImportLogins)
}

@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ data class UserState(
val activeAccount: Account
get() = accounts.first { it.userId == activeUserId }

val activeUserFirstTimeState: FirstTimeState
get() = activeAccount.firstTimeState

/**
* Basic account information about a given user.
*
Expand Down Expand Up @@ -71,6 +74,7 @@ data class UserState(
val vaultUnlockType: VaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
val isUsingKeyConnector: Boolean,
val onboardingStatus: OnboardingStatus,
val firstTimeState: FirstTimeState,
) {
/**
* Indicates that the user does or does not have a means to manually unlock the vault.
Expand All @@ -91,4 +95,21 @@ data class UserState(
val hasLoginApprovingDevice: Boolean,
val hasResetPasswordPermission: Boolean,
)

/**
* Model to encapsulate different states for a user's first time experience.
*/
data class FirstTimeState(
val showImportLoginsCard: Boolean,
) {
/**
* Constructs a [FirstTimeState] accepting nullable values. If a value is null, the default
* is used.
*/
constructor(
showImportLoginsCoachMarker: Boolean?,
) : this(
showImportLoginsCard = showImportLoginsCoachMarker ?: true,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.UserSwitchingData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -183,8 +184,50 @@ val AuthDiskSource.onboardingStatusChangesFlow: Flow<OnboardingStatus?>
}
.distinctUntilChanged()

/**
* Returns the current [OnboardingStatus] of the active user.
*/
val AuthDiskSource.currentOnboardingStatus: OnboardingStatus?
get() = this
.userState
?.activeUserId
?.let { this.getOnboardingStatus(userId = it) }

/**
* Returns a [Flow] that emits every time the active user's first time state is changed.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val AuthDiskSource.firstTimeStateFlow: Flow<UserState.FirstTimeState>
get() = activeUserIdChangesFlow
.flatMapLatest { activeUserId ->
combine(
listOf(
activeUserId
?.let {
getShowImportLoginsFlow(it)
}
?: flowOf(null),
),
) {
UserState.FirstTimeState(
showImportLoginsCoachMarker = it[0],
)
}
}
.distinctUntilChanged()

/**
* Get the current [UserState.FirstTimeState] of the active user if available, otherwise return
* a default configuration.
*/
val AuthDiskSource.currentOrDefaultUserFirstTimeState
get() = userState
?.activeUserId
?.let {
UserState.FirstTimeState(
showImportLoginsCoachMarker = getShowImportLogins(it),
)
}
?: UserState.FirstTimeState(
showImportLoginsCoachMarker = true,
)
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ fun UserStateJson.toUserState(
userIsUsingKeyConnectorList: List<UserKeyConnectorState>,
hasPendingAccountAddition: Boolean,
onboardingStatus: OnboardingStatus?,
firstTimeState: UserState.FirstTimeState,
isBiometricsEnabledProvider: (userId: String) -> Boolean,
vaultUnlockTypeProvider: (userId: String) -> VaultUnlockType,
isDeviceTrustedProvider: (userId: String) -> Boolean,
Expand Down Expand Up @@ -180,6 +181,7 @@ fun UserStateJson.toUserState(
// If the user exists with no onboarding status we can assume they have been
// using the app prior to the release of the onboarding flow.
onboardingStatus = onboardingStatus ?: OnboardingStatus.COMPLETE,
firstTimeState = firstTimeState,
)
},
hasPendingAccountAddition = hasPendingAccountAddition,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ class SettingsDiskSourceImpl(
// The following are intentionally not cleared so they can be
// restored after logging out and back in:
// - screen capture allowed
// - show autofill setting badge
// - show unlock setting badge
}

override fun getAccountBiometricIntegrityValidity(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ sealed class FlagKey<out T : Any> {
EmailVerification,
OnboardingFlow,
OnboardingCarousel,
ImportLoginsFlow,
)
}
}
Expand Down Expand Up @@ -70,6 +71,15 @@ sealed class FlagKey<out T : Any> {
override val isRemotelyConfigured: Boolean = false
}

/**
* Data object holding the feature flag key for the import logins feature.
*/
data object ImportLoginsFlow : FlagKey<Boolean>() {
override val keyName: String = "import-logins-flow"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = false
}

/**
* Data object holding the key for a [Boolean] flag to be used in tests.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ fun BitwardenActionCard(
onActionClick: () -> Unit,
onDismissClick: () -> Unit,
modifier: Modifier = Modifier,
cardSubtitle: String? = null,
leadingContent: @Composable (() -> Unit)? = null,
) {
Card(
Expand All @@ -70,7 +71,6 @@ fun BitwardenActionCard(
Text(
text = cardTitle,
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
)
Spacer(Modifier.weight(1f))
BitwardenStandardIconButton(
Expand All @@ -80,6 +80,13 @@ fun BitwardenActionCard(
modifier = Modifier.offset(x = 8.dp),
)
}
cardSubtitle?.let {
Spacer(Modifier.height(4.dp))
Text(
text = it,
style = BitwardenTheme.typography.bodyMedium,
)
}
Spacer(Modifier.height(16.dp))
BitwardenFilledButton(
label = actionText,
Expand Down Expand Up @@ -128,3 +135,23 @@ private fun BitwardenActionCardWithLeadingContent_preview() {
)
}
}

@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun BitwardenActionCardWithSubtitle_preview() {
BitwardenTheme {
BitwardenActionCard(
cardTitle = "Title",
cardSubtitle = "Subtitle",
actionText = "Action",
onActionClick = {},
onDismissClick = {},
leadingContent = {
NotificationBadge(
notificationCount = 1,
)
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ fun <T : Any> FlagKey<T>.ListItemContent(
FlagKey.EmailVerification,
FlagKey.OnboardingCarousel,
FlagKey.OnboardingFlow,
FlagKey.ImportLoginsFlow,
-> BooleanFlagItem(
label = flagKey.getDisplayLabel(),
key = flagKey as FlagKey<Boolean>,
Expand Down Expand Up @@ -67,4 +68,5 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
FlagKey.EmailVerification -> stringResource(R.string.email_verification)
FlagKey.OnboardingCarousel -> stringResource(R.string.onboarding_carousel)
FlagKey.OnboardingFlow -> stringResource(R.string.onboarding_flow)
FlagKey.ImportLoginsFlow -> stringResource(R.string.import_logins_flow)
}
Loading

0 comments on commit ba8e3a6

Please sign in to comment.