[PM-19108] Add Privileged Apps List Screen (#5372)

This commit is contained in:
Patrick Honkonen 2025-06-25 12:41:48 -04:00 committed by GitHub
parent fbfcfcd683
commit ddc099f727
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1951 additions and 40 deletions

View File

@ -330,6 +330,11 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
</intent>
<!-- To Query Privileged Apps -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" />
</intent>
<!-- To Query Chrome Beta: -->
<package android:name="com.chrome.beta" />

View File

@ -118,9 +118,13 @@ object CredentialProviderModule {
@Singleton
fun providePrivilegedAppRepository(
privilegedAppDiskSource: PrivilegedAppDiskSource,
assetManager: AssetManager,
dispatcherManager: DispatcherManager,
json: Json,
): PrivilegedAppRepository = PrivilegedAppRepositoryImpl(
privilegedAppDiskSource = privilegedAppDiskSource,
assetManager = assetManager,
dispatcherManager = dispatcherManager,
json = json,
)

View File

@ -1,22 +1,49 @@
package com.x8bit.bitwarden.data.credentials.repository
import com.bitwarden.core.data.repository.model.DataState
import com.x8bit.bitwarden.data.credentials.model.PrivilegedAppAllowListJson
import kotlinx.coroutines.flow.Flow
import com.x8bit.bitwarden.data.credentials.repository.model.PrivilegedAppData
import kotlinx.coroutines.flow.StateFlow
/**
* Repository for managing privileged apps trusted by the user.
*/
interface PrivilegedAppRepository {
/**
* Flow that represents the trusted privileged apps data.
*/
val trustedAppDataStateFlow: StateFlow<DataState<PrivilegedAppData>>
/**
* Flow of the user's trusted privileged apps.
*/
val userTrustedPrivilegedAppsFlow: Flow<PrivilegedAppAllowListJson>
val userTrustedAppsFlow: StateFlow<DataState<PrivilegedAppAllowListJson>>
/**
* Flow of the Google's trusted privileged apps.
*/
val googleTrustedPrivilegedAppsFlow: StateFlow<DataState<PrivilegedAppAllowListJson>>
/**
* Flow of the community's trusted privileged apps.
*/
val communityTrustedAppsFlow: StateFlow<DataState<PrivilegedAppAllowListJson>>
/**
* List the user's trusted privileged apps.
*/
suspend fun getAllUserTrustedPrivilegedApps(): PrivilegedAppAllowListJson
suspend fun getUserTrustedPrivilegedAppsOrNull(): PrivilegedAppAllowListJson?
/**
* List Google's trusted privileged apps.
*/
suspend fun getGoogleTrustedPrivilegedAppsOrNull(): PrivilegedAppAllowListJson?
/**
* List community's trusted privileged apps.
*/
suspend fun getCommunityTrustedPrivilegedAppsOrNull(): PrivilegedAppAllowListJson?
/**
* Returns true if the given [packageName] and [signature] are trusted.

View File

@ -1,12 +1,35 @@
package com.x8bit.bitwarden.data.credentials.repository
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.combineDataStates
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.data.manager.DispatcherManager
import com.x8bit.bitwarden.data.credentials.datasource.disk.PrivilegedAppDiskSource
import com.x8bit.bitwarden.data.credentials.datasource.disk.entity.PrivilegedAppEntity
import com.x8bit.bitwarden.data.credentials.model.PrivilegedAppAllowListJson
import kotlinx.coroutines.flow.Flow
import com.x8bit.bitwarden.data.credentials.repository.model.PrivilegedAppData
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
/**
* A "stop timeout delay" in milliseconds used to let a shared coroutine continue to run for the
* specified period of time after it no longer has subscribers.
*/
private const val STOP_TIMEOUT_DELAY_MS: Long = 1000L
private const val GOOGLE_ALLOW_LIST_FILE_NAME = "fido2_privileged_google.json"
private const val COMMUNITY_ALLOW_LIST_FILE_NAME = "fido2_privileged_community.json"
private const val ANDROID_TYPE = "android"
private const val RELEASE_BUILD = "release"
@ -15,17 +38,102 @@ private const val RELEASE_BUILD = "release"
*/
class PrivilegedAppRepositoryImpl(
private val privilegedAppDiskSource: PrivilegedAppDiskSource,
private val assetManager: AssetManager,
dispatcherManager: DispatcherManager,
private val json: Json,
) : PrivilegedAppRepository {
override val userTrustedPrivilegedAppsFlow: Flow<PrivilegedAppAllowListJson> =
privilegedAppDiskSource.userTrustedPrivilegedAppsFlow
.map { it.toPrivilegedAppAllowListJson() }
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val ioScope = CoroutineScope(dispatcherManager.io)
override suspend fun getAllUserTrustedPrivilegedApps(): PrivilegedAppAllowListJson =
privilegedAppDiskSource.getAllUserTrustedPrivilegedApps()
private val mutableUserTrustedAppsFlow =
MutableStateFlow<DataState<PrivilegedAppAllowListJson>>(DataState.Loading)
private val mutableGoogleTrustedAppsFlow =
MutableStateFlow<DataState<PrivilegedAppAllowListJson>>(DataState.Loading)
private val mutableCommunityTrustedPrivilegedAppsFlow =
MutableStateFlow<DataState<PrivilegedAppAllowListJson>>(DataState.Loading)
override val trustedAppDataStateFlow: StateFlow<DataState<PrivilegedAppData>> =
combine(
userTrustedAppsFlow,
googleTrustedPrivilegedAppsFlow,
communityTrustedAppsFlow,
) { userAppsState, googleAppsState, communityAppsState ->
combineDataStates(
userAppsState,
googleAppsState,
communityAppsState,
) { userApps, googleApps, communityApps ->
PrivilegedAppData(
googleTrustedApps = googleApps,
communityTrustedApps = communityApps,
userTrustedApps = userApps,
)
}
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = STOP_TIMEOUT_DELAY_MS),
initialValue = DataState.Loading,
)
override val userTrustedAppsFlow: StateFlow<DataState<PrivilegedAppAllowListJson>>
get() = mutableUserTrustedAppsFlow.asStateFlow()
override val googleTrustedPrivilegedAppsFlow: StateFlow<DataState<PrivilegedAppAllowListJson>>
get() = mutableGoogleTrustedAppsFlow.asStateFlow()
override val communityTrustedAppsFlow: StateFlow<DataState<PrivilegedAppAllowListJson>>
get() = mutableCommunityTrustedPrivilegedAppsFlow.asStateFlow()
init {
ioScope.launch {
mutableGoogleTrustedAppsFlow.value = assetManager
.readAsset(fileName = GOOGLE_ALLOW_LIST_FILE_NAME)
.map { json.decodeFromString<PrivilegedAppAllowListJson>(it) }
.fold(
onSuccess = { DataState.Loaded(it) },
onFailure = { DataState.Error(it) },
)
mutableCommunityTrustedPrivilegedAppsFlow.value = assetManager
.readAsset(fileName = COMMUNITY_ALLOW_LIST_FILE_NAME)
.map { json.decodeFromString<PrivilegedAppAllowListJson>(it) }
.fold(
onSuccess = { DataState.Loaded(it) },
onFailure = { DataState.Error(it) },
)
}
privilegedAppDiskSource
.userTrustedPrivilegedAppsFlow
.map { DataState.Loaded(it.toPrivilegedAppAllowListJson()) }
.onEach { mutableUserTrustedAppsFlow.value = it }
.launchIn(ioScope)
}
override suspend fun getUserTrustedPrivilegedAppsOrNull(): PrivilegedAppAllowListJson =
privilegedAppDiskSource
.getAllUserTrustedPrivilegedApps()
.toPrivilegedAppAllowListJson()
override suspend fun getGoogleTrustedPrivilegedAppsOrNull(): PrivilegedAppAllowListJson? =
withContext(ioScope.coroutineContext) {
assetManager
.readAsset(fileName = GOOGLE_ALLOW_LIST_FILE_NAME)
.map { json.decodeFromStringOrNull<PrivilegedAppAllowListJson>(it) }
.getOrNull()
}
override suspend fun getCommunityTrustedPrivilegedAppsOrNull(): PrivilegedAppAllowListJson? {
return withContext(ioScope.coroutineContext) {
assetManager
.readAsset(fileName = COMMUNITY_ALLOW_LIST_FILE_NAME)
.map { json.decodeFromStringOrNull<PrivilegedAppAllowListJson>(it) }
.getOrNull()
}
}
override suspend fun isPrivilegedAppAllowed(
packageName: String,
signature: String,

View File

@ -0,0 +1,12 @@
package com.x8bit.bitwarden.data.credentials.repository.model
import com.x8bit.bitwarden.data.credentials.model.PrivilegedAppAllowListJson
/**
* Represents privileged applications that are trusted by various sources.
*/
data class PrivilegedAppData(
val googleTrustedApps: PrivilegedAppAllowListJson,
val communityTrustedApps: PrivilegedAppAllowListJson,
val userTrustedApps: PrivilegedAppAllowListJson,
)

View File

@ -22,6 +22,8 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.autoFillDestina
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill.blockAutoFillDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.blockautofill.navigateToBlockAutoFillScreen
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.navigateToAutoFill
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.list.navigateToPrivilegedAppsList
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.list.privilegedAppsListDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.flightRecorderDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.navigateToFlightRecorder
import com.x8bit.bitwarden.ui.platform.feature.settings.flightrecorder.recordedLogs.navigateToRecordedLogs
@ -149,6 +151,7 @@ fun NavGraphBuilder.settingsGraph(
onNavigateToBlockAutoFillScreen = { navController.navigateToBlockAutoFillScreen() },
onNavigateToSetupAutofill = onNavigateToSetupAutoFillScreen,
onNavigateToAboutPrivilegedAppsScreen = onNavigateToAboutPrivilegedApps,
onNavigateToPrivilegedAppsList = { navController.navigateToPrivilegedAppsList() },
)
otherDestination(
isPreAuth = false,
@ -161,6 +164,7 @@ fun NavGraphBuilder.settingsGraph(
onNavigateToImportLogins = onNavigateToImportLogins,
)
blockAutoFillDestination(onNavigateBack = { navController.popBackStack() })
privilegedAppsListDestination(onNavigateBack = { navController.popBackStack() })
}
}

View File

@ -22,6 +22,7 @@ fun NavGraphBuilder.autoFillDestination(
onNavigateToBlockAutoFillScreen: () -> Unit,
onNavigateToSetupAutofill: () -> Unit,
onNavigateToAboutPrivilegedAppsScreen: () -> Unit,
onNavigateToPrivilegedAppsList: () -> Unit,
) {
composableWithPushTransitions<AutofillRoute> {
AutoFillScreen(
@ -29,6 +30,7 @@ fun NavGraphBuilder.autoFillDestination(
onNavigateToBlockAutoFillScreen = onNavigateToBlockAutoFillScreen,
onNavigateToSetupAutofill = onNavigateToSetupAutofill,
onNavigateToAboutPrivilegedAppsScreen = onNavigateToAboutPrivilegedAppsScreen,
onNavigateToPrivilegedAppsList = onNavigateToPrivilegedAppsList,
)
}
}

View File

@ -69,6 +69,7 @@ fun AutoFillScreen(
onNavigateToBlockAutoFillScreen: () -> Unit,
onNavigateToSetupAutofill: () -> Unit,
onNavigateToAboutPrivilegedAppsScreen: () -> Unit,
onNavigateToPrivilegedAppsList: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
@ -110,6 +111,10 @@ fun AutoFillScreen(
AutoFillEvent.NavigateToAboutPrivilegedAppsScreen -> {
onNavigateToAboutPrivilegedAppsScreen()
}
AutoFillEvent.NavigateToPrivilegedAppsListScreen -> {
onNavigateToPrivilegedAppsList()
}
}
}

View File

@ -124,6 +124,11 @@ class AutoFillViewModel @Inject constructor(
AutoFillAction.DismissShowAutofillActionCard -> handleDismissShowAutofillActionCard()
is AutoFillAction.BrowserAutofillSelected -> handleBrowserAutofillSelected(action)
AutoFillAction.AboutPrivilegedAppsClick -> handleAboutPrivilegedAppsClick()
AutoFillAction.PrivilegedAppsClick -> handlePrivilegedAppsClick()
}
private fun handlePrivilegedAppsClick() {
sendEvent(AutoFillEvent.NavigateToPrivilegedAppsListScreen)
}
private fun handleInternalAction(action: AutoFillAction.Internal) {
@ -363,6 +368,11 @@ sealed class AutoFillEvent {
* Navigate to the about privileged apps screen.
*/
data object NavigateToAboutPrivilegedAppsScreen : AutoFillEvent()
/**
* Navigate to the privileged apps list screen.
*/
data object NavigateToPrivilegedAppsListScreen : AutoFillEvent()
}
/**
@ -444,6 +454,11 @@ sealed class AutoFillAction {
*/
data object AboutPrivilegedAppsClick : AutoFillAction()
/**
* User has clicked the privileged apps row.
*/
data object PrivilegedAppsClick : AutoFillAction()
/**
* Internal actions.
*/

View File

@ -61,7 +61,7 @@ class AutoFillHandlers(
viewModel.trySendAction(AutoFillAction.PasskeyManagementClick)
},
onPrivilegedAppsClick = {
// TODO (PM-19108): Open privileged apps screen when available
viewModel.trySendAction(AutoFillAction.PrivilegedAppsClick)
},
onPrivilegedAppsHelpLinkClick = {
viewModel.trySendAction(AutoFillAction.AboutPrivilegedAppsClick)

View File

@ -0,0 +1,34 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.list
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import kotlinx.serialization.Serializable
/**
* Type-safe route object for navigating to the privileged apps list screen.
*/
@Serializable
data object PrivilegedAppListRoute
/**
* Add privileged apps list destination to the nav graph.
*/
fun NavGraphBuilder.privilegedAppsListDestination(
onNavigateBack: () -> Unit,
) {
composableWithPushTransitions<PrivilegedAppListRoute> {
PrivilegedAppsListScreen(onNavigateBack = onNavigateBack)
}
}
/**
* Navigate to the privileged apps list screen.
*/
fun NavController.navigateToPrivilegedAppsList(navOptions: NavOptions? = null) {
navigate(route = PrivilegedAppListRoute, navOptions = navOptions)
}

View File

@ -0,0 +1,406 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.list
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenExpandingHeader
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.row.BitwardenTextRow
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.list.model.PrivilegedAppListItem
import kotlinx.collections.immutable.persistentListOf
/**
* Top level composable for the privileged apps list.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivilegedAppsListScreen(
onNavigateBack: () -> Unit,
viewModel: PrivilegedAppsListViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
EventsEffect(viewModel) { event ->
when (event) {
PrivilegedAppsListEvent.NavigateBack -> onNavigateBack()
}
}
PrivilegedAppListDialogs(
state = state,
onDismissDialogClick = remember(viewModel) {
{ viewModel.trySendAction(PrivilegedAppsListAction.DismissDialogClick) }
},
onConfirmDeleteTrustedAppClick = remember(viewModel) {
{
viewModel.trySendAction(
PrivilegedAppsListAction.UserTrustedAppDeleteConfirmClick(it),
)
}
},
)
BitwardenScaffold(
topBar = {
BitwardenTopAppBar(
title = stringResource(R.string.privileged_apps),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
navigationIconContentDescription = stringResource(id = R.string.back),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(PrivilegedAppsListAction.BackClick) }
},
)
},
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
) {
PrivilegedAppsListContent(
state = state,
onDeleteClick = remember(viewModel) {
{ viewModel.trySendAction(PrivilegedAppsListAction.UserTrustedAppDeleteClick(it)) }
},
modifier = Modifier
.fillMaxSize()
.standardHorizontalMargin(),
)
}
}
@Composable
private fun PrivilegedAppListDialogs(
state: PrivilegedAppsListState,
onDismissDialogClick: () -> Unit,
onConfirmDeleteTrustedAppClick: (app: PrivilegedAppListItem) -> Unit,
) {
when (val dialogState = state.dialogState) {
is PrivilegedAppsListState.DialogState.Loading -> {
BitwardenLoadingDialog(stringResource(R.string.loading))
}
is PrivilegedAppsListState.DialogState.ConfirmDeleteTrustedApp -> {
BitwardenTwoButtonDialog(
title = stringResource(R.string.delete),
message = stringResource(
R.string.are_you_sure_you_want_to_stop_trusting_x,
dialogState.app.packageName,
),
confirmButtonText = stringResource(R.string.okay),
dismissButtonText = stringResource(R.string.cancel),
onConfirmClick = { onConfirmDeleteTrustedAppClick(dialogState.app) },
onDismissClick = onDismissDialogClick,
onDismissRequest = onDismissDialogClick,
)
}
is PrivilegedAppsListState.DialogState.General -> {
BitwardenBasicDialog(
title = dialogState.title?.invoke()
?: stringResource(R.string.an_error_has_occurred),
message = dialogState.message.invoke(),
onDismissRequest = onDismissDialogClick,
)
}
null -> Unit
}
}
@Suppress("LongMethod")
@Composable
private fun PrivilegedAppsListContent(
state: PrivilegedAppsListState,
onDeleteClick: (PrivilegedAppListItem) -> Unit,
modifier: Modifier = Modifier,
) {
var showAllTrustedApps by rememberSaveable { mutableStateOf(false) }
LazyColumn(
modifier = modifier,
) {
if (state.installedApps.isNotEmpty()) {
item(key = "installed_apps") {
Spacer(
modifier = Modifier
.height(12.dp)
.animateItem(),
)
BitwardenListHeaderText(
label = stringResource(R.string.installed_apps),
supportingLabel = state.installedApps.size.toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(
modifier = Modifier
.height(8.dp)
.animateItem(),
)
}
itemsIndexed(
key = { _, item -> "installedApp_$item" },
items = state.installedApps,
) { index, item ->
BitwardenTextRow(
text = item.label,
description = stringResource(
R.string.trusted_by_x,
stringResource(item.trustAuthority.displayName),
),
onClick = {},
cardStyle = state.installedApps
.toListItemCardStyle(index),
modifier = Modifier
.fillMaxWidth()
.animateItem(),
) {
if (item.canRevokeTrust) {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_delete,
contentDescription =
stringResource(R.string.delete_x, item.packageName),
onClick = remember(item) {
{ onDeleteClick(item) }
},
)
}
}
}
}
if (state.notInstalledApps.isNotEmpty()) {
item {
BitwardenExpandingHeader(
collapsedText = stringResource(R.string.all_trusted_apps),
isExpanded = showAllTrustedApps,
onClick = remember(state) {
{ showAllTrustedApps = !showAllTrustedApps }
},
modifier = Modifier
.fillMaxWidth()
.animateItem(),
)
}
}
if (showAllTrustedApps) {
if (state.notInstalledUserTrustedApps.isNotEmpty()) {
item(key = "trusted_by_you") {
Spacer(
modifier = Modifier
.height(12.dp)
.animateItem(),
)
BitwardenListHeaderText(
label = stringResource(R.string.trusted_by_you),
supportingLabel = state.notInstalledUserTrustedApps.size.toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(
modifier = Modifier
.height(8.dp)
.animateItem(),
)
}
itemsIndexed(
key = { _, item -> "userTrust_$item" },
items = state.notInstalledUserTrustedApps,
) { index, item ->
BitwardenTextRow(
text = item.label,
onClick = {},
cardStyle = state.notInstalledUserTrustedApps
.toListItemCardStyle(index),
modifier = Modifier
.fillMaxWidth()
.animateItem(),
) {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_delete,
contentDescription = "",
onClick = remember(item) {
{ onDeleteClick(item) }
},
)
}
}
}
if (state.notInstalledCommunityTrustedApps.isNotEmpty()) {
item(key = "trusted_by_community") {
Spacer(
modifier = Modifier
.height(
if (state.notInstalledUserTrustedApps.isEmpty()) {
12.dp
} else {
16.dp
},
)
.animateItem(),
)
BitwardenListHeaderText(
label = stringResource(R.string.trusted_by_the_community),
supportingLabel = state.notInstalledCommunityTrustedApps.size.toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(
modifier = Modifier
.height(8.dp)
.animateItem(),
)
}
itemsIndexed(
key = { _, item -> "communityTrust_$item" },
items = state.notInstalledCommunityTrustedApps,
) { index, item ->
BitwardenTextRow(
text = item.label,
onClick = {},
cardStyle = state.notInstalledCommunityTrustedApps
.toListItemCardStyle(index),
modifier = Modifier
.animateItem(),
)
}
}
if (state.notInstalledGoogleTrustedApps.isNotEmpty()) {
item(key = "trusted_by_google") {
Spacer(modifier = Modifier.height(16.dp))
BitwardenListHeaderText(
label = stringResource(R.string.trusted_by_google),
supportingLabel = state.notInstalledGoogleTrustedApps.size.toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(
modifier = Modifier
.height(8.dp)
.animateItem(),
)
}
itemsIndexed(
key = { _, item -> "googleTrust_$item" },
items = state.notInstalledGoogleTrustedApps,
) { index, item ->
BitwardenTextRow(
text = item.label,
onClick = {},
cardStyle = state.notInstalledGoogleTrustedApps
.toListItemCardStyle(index),
modifier = Modifier
.animateItem(),
)
}
}
}
item {
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
// region Previews
@Preview(showBackground = true)
@Composable
private fun PrivilegedAppsListScreen_Preview() {
PrivilegedAppsListContent(
state = PrivilegedAppsListState(
installedApps = persistentListOf(
PrivilegedAppListItem(
packageName = "com.x8bit.bitwarden.google",
signature = "XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX",
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.USER,
appName = null,
),
PrivilegedAppListItem(
packageName = "com.bitwarden.authenticator.google",
signature = "XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX",
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.COMMUNITY,
appName = "Bitwarden",
),
PrivilegedAppListItem(
packageName = "com.google.android.apps.walletnfcrel.google",
signature = "XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX",
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.GOOGLE,
appName = "Bitwarden",
),
),
notInstalledApps = persistentListOf(
PrivilegedAppListItem(
packageName = "com.x8bit.bitwarden.community",
signature = "XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX",
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.USER,
appName = "Bitwarden",
),
PrivilegedAppListItem(
packageName = "com.bitwarden.authenticator.community",
signature = "XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX",
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.COMMUNITY,
appName = "Bitwarden",
),
PrivilegedAppListItem(
packageName = "com.google.android.apps.walletnfcrel.community",
signature = "XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX",
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.GOOGLE,
appName = "Bitwarden",
),
),
dialogState = null,
),
onDeleteClick = {},
)
}
//endregion Previews

View File

@ -0,0 +1,342 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.list
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.manager.BitwardenPackageManager
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.credentials.model.PrivilegedAppAllowListJson
import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository
import com.x8bit.bitwarden.data.credentials.repository.model.PrivilegedAppData
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.list.model.PrivilegedAppListItem
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the [PrivilegedAppsListScreen].
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class PrivilegedAppsListViewModel @Inject constructor(
private val privilegedAppRepository: PrivilegedAppRepository,
private val bitwardenPackageManager: BitwardenPackageManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<PrivilegedAppsListState, PrivilegedAppsListEvent, PrivilegedAppsListAction>(
initialState = savedStateHandle[KEY_STATE]
?: PrivilegedAppsListState(
installedApps = persistentListOf(),
notInstalledApps = persistentListOf(),
dialogState = null,
),
) {
init {
privilegedAppRepository
.trustedAppDataStateFlow
.map { PrivilegedAppsListAction.Internal.PrivilegedAppDataStateReceive(it) }
.onEach(::handleAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: PrivilegedAppsListAction) {
when (action) {
is PrivilegedAppsListAction.UserTrustedAppDeleteClick -> {
handleUserTrustedAppDeleteClick(action.app)
}
is PrivilegedAppsListAction.UserTrustedAppDeleteConfirmClick -> {
handleUserTrustedAppDeleteConfirmClick(action.app)
}
is PrivilegedAppsListAction.DismissDialogClick -> {
handleDismissDialogClick()
}
is PrivilegedAppsListAction.BackClick -> handleBackClick()
is PrivilegedAppsListAction.Internal.PrivilegedAppDataStateReceive -> {
handleTrustedAppDataStateReceive(action.dataState)
}
}
}
private fun handleBackClick() {
sendEvent(PrivilegedAppsListEvent.NavigateBack)
}
private fun handleDismissDialogClick() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleTrustedAppDataStateReceive(dataState: DataState<PrivilegedAppData>) {
when (dataState) {
is DataState.Loaded -> handleTrustedAppDataStateLoaded(dataState)
DataState.Loading -> handleTrustedAppDataStateLoading()
is DataState.Pending -> handleTrustedAppDataStatePending(dataState)
is DataState.Error -> handleTrustedAppDataStateError()
// Network connection is not required so we ignore NoNetwork state.
is DataState.NoNetwork -> handleTrustedAppDataStateNoNetwork(dataState)
}
}
private fun handleTrustedAppDataStateNoNetwork(
dataState: DataState.NoNetwork<PrivilegedAppData>,
) {
updateViewStateWithData(data = dataState.data, dialogState = null)
}
private fun handleTrustedAppDataStateError() {
mutableStateFlow.update {
it.copy(
dialogState = PrivilegedAppsListState.DialogState.General(
message = R.string.generic_error_message.asText(),
),
)
}
}
private fun handleTrustedAppDataStateLoaded(
state: DataState.Loaded<PrivilegedAppData>,
) {
updateViewStateWithData(data = state.data, dialogState = null)
}
private fun handleTrustedAppDataStateLoading() {
mutableStateFlow.update {
it.copy(dialogState = PrivilegedAppsListState.DialogState.Loading)
}
}
private fun handleTrustedAppDataStatePending(
state: DataState.Pending<PrivilegedAppData>,
) {
updateViewStateWithData(state.data, PrivilegedAppsListState.DialogState.Loading)
}
private fun handleUserTrustedAppDeleteClick(app: PrivilegedAppListItem) {
mutableStateFlow.update {
it.copy(
dialogState = PrivilegedAppsListState.DialogState.ConfirmDeleteTrustedApp(app),
)
}
}
private fun handleUserTrustedAppDeleteConfirmClick(app: PrivilegedAppListItem) {
mutableStateFlow.update {
it.copy(
dialogState = PrivilegedAppsListState.DialogState.Loading,
)
}
viewModelScope.launch {
privilegedAppRepository
.removeTrustedPrivilegedApp(
packageName = app.packageName,
signature = app.signature,
)
}
}
private fun updateViewStateWithData(
data: PrivilegedAppData?,
dialogState: PrivilegedAppsListState.DialogState?,
) {
val notInstalledApps = mutableListOf<PrivilegedAppListItem>()
val installedApps = mutableListOf<PrivilegedAppListItem>()
data
?.googleTrustedApps
?.toPrivilegedAppList(
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.GOOGLE,
)
?.sortIntoInstalledAndNotInstalledCollections(
installedApps = installedApps,
notInstalledApps = notInstalledApps,
)
data
?.communityTrustedApps
?.toPrivilegedAppList(
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.COMMUNITY,
)
?.sortIntoInstalledAndNotInstalledCollections(
installedApps = installedApps,
notInstalledApps = notInstalledApps,
)
data
?.userTrustedApps
?.toPrivilegedAppList(
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.USER,
)
?.sortIntoInstalledAndNotInstalledCollections(
installedApps = installedApps,
notInstalledApps = notInstalledApps,
)
mutableStateFlow.update { state ->
state.copy(
installedApps = installedApps
.sortedBy { it.appName ?: it.packageName }
.toImmutableList(),
notInstalledApps = notInstalledApps
.sortedBy { it.packageName }
.toImmutableList(),
dialogState = dialogState,
)
}
}
private fun List<PrivilegedAppListItem>.sortIntoInstalledAndNotInstalledCollections(
installedApps: MutableList<PrivilegedAppListItem>,
notInstalledApps: MutableList<PrivilegedAppListItem>,
) {
this.forEach {
if (bitwardenPackageManager.isPackageInstalled(it.packageName)) {
installedApps.add(it)
} else {
notInstalledApps.add(it)
}
}
}
private fun PrivilegedAppAllowListJson.toPrivilegedAppList(
trustAuthority: PrivilegedAppListItem.PrivilegedAppTrustAuthority,
) = this.apps
.map { it.toPrivilegedAppListItem(trustAuthority) }
private fun PrivilegedAppAllowListJson.PrivilegedAppJson.toPrivilegedAppListItem(
trustAuthority: PrivilegedAppListItem.PrivilegedAppTrustAuthority,
) = PrivilegedAppListItem(
packageName = info.packageName,
signature = info.signatures
.first()
.certFingerprintSha256,
trustAuthority = trustAuthority,
appName = bitwardenPackageManager
.getAppLabelForPackageOrNull(info.packageName),
)
}
/**
* Models the state of the [PrivilegedAppsListViewModel].
*/
@Parcelize
data class PrivilegedAppsListState(
val installedApps: ImmutableList<PrivilegedAppListItem>,
val notInstalledApps: ImmutableList<PrivilegedAppListItem>,
val dialogState: DialogState?,
) : Parcelable {
@IgnoredOnParcel
val notInstalledUserTrustedApps = notInstalledApps
.filter {
it.trustAuthority == PrivilegedAppListItem.PrivilegedAppTrustAuthority.USER
}
@IgnoredOnParcel
val notInstalledCommunityTrustedApps = notInstalledApps
.filter {
it.trustAuthority == PrivilegedAppListItem.PrivilegedAppTrustAuthority.COMMUNITY
}
@IgnoredOnParcel
val notInstalledGoogleTrustedApps = notInstalledApps
.filter {
it.trustAuthority == PrivilegedAppListItem.PrivilegedAppTrustAuthority.GOOGLE
}
/**
* Models the different dialog states that the [PrivilegedAppsListViewModel] may be in.
*/
sealed class DialogState : Parcelable {
/**
* Show the loading dialog.
*/
@Parcelize
data object Loading : DialogState()
/**
* Show the confirm delete trusted app dialog.
*/
@Parcelize
data class ConfirmDeleteTrustedApp(
val app: PrivilegedAppListItem,
) : DialogState()
/**
* Show a general dialog.
*/
@Parcelize
data class General(
val title: Text? = null,
val message: Text,
) : DialogState()
}
}
/**
* Models events that the [PrivilegedAppsListViewModel] may send.
*/
sealed class PrivilegedAppsListEvent {
/**
* Navigate back to the previous screen.
*/
data object NavigateBack : PrivilegedAppsListEvent()
}
/**
* Models actions that the [PrivilegedAppsListViewModel] may receive.
*/
sealed class PrivilegedAppsListAction {
/**
* Navigate back to the previous screen.
*/
data object BackClick : PrivilegedAppsListAction()
/**
* The user has dismissed the current dialog.
*/
data object DismissDialogClick : PrivilegedAppsListAction()
/**
* The user has selected to delete a trusted app from their local trust store.
*/
data class UserTrustedAppDeleteClick(
val app: PrivilegedAppListItem,
) : PrivilegedAppsListAction()
/**
* The user has confirmed that they want to delete a trusted app from their local trust store.
*/
data class UserTrustedAppDeleteConfirmClick(
val app: PrivilegedAppListItem,
) : PrivilegedAppsListAction()
/**
* Models actions that the [PrivilegedAppsListViewModel] itself may send.
*/
sealed class Internal : PrivilegedAppsListAction() {
/**
* Indicates that the trusted app data state has been received.
*/
data class PrivilegedAppDataStateReceive(
val dataState: DataState<PrivilegedAppData>,
) : Internal()
}
}

View File

@ -0,0 +1,53 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.list.model
import android.os.Parcelable
import androidx.annotation.StringRes
import com.x8bit.bitwarden.R
import kotlinx.parcelize.Parcelize
/**
* Represents a single item in the list of trusted privileged apps.
*
* @param packageName The package name of the privileged app.
* @param signature The signature of the privileged app.
*/
@Parcelize
data class PrivilegedAppListItem(
val packageName: String,
val signature: String,
val trustAuthority: PrivilegedAppTrustAuthority,
val appName: String? = null,
) : Parcelable {
val canRevokeTrust: Boolean
get() = trustAuthority == PrivilegedAppTrustAuthority.USER
val label: String
get() = if (appName == null) {
packageName
} else {
"$appName ($packageName)"
}
/**
* Represents the trust authority of a privileged app.
*/
enum class PrivilegedAppTrustAuthority(
@StringRes val displayName: Int,
) {
/**
* The app is trusted by Google.
*/
GOOGLE(displayName = R.string.google),
/**
* The app is trusted by the Bitwarden community.
*/
COMMUNITY(displayName = R.string.the_community),
/**
* The app is trusted by the user.
*/
USER(displayName = R.string.you),
}
}

View File

@ -982,4 +982,13 @@ Do you want to switch to this account?</string>
<string name="learn_more_about_privileged_apps">Learn more about privileged apps</string>
<string name="privileged_apps">Privileged apps</string>
<string name="unrecognized_browser">Unrecognized browser</string>
<string name="learn_more_about_using_passkeys_with_bitwarden">Learn more about using passkeys with Bitwarden.</string>
<string name="google">Google</string>
<string name="the_community">the Community</string>
<string name="you">YOU</string>
<string name="all_trusted_apps">All trusted apps</string>
<string name="trusted_by_x">Trusted by %s</string>
<string name="are_you_sure_you_want_to_stop_trusting_x">Are you sure you want to stop trusting %s?</string>
<string name="installed_apps">Installed apps</string>
<string name="delete_x">Delete %s</string>
</resources>

View File

@ -1,9 +1,16 @@
package com.x8bit.bitwarden.data.credentials.repository
import app.cash.turbine.test
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.credentials.datasource.disk.PrivilegedAppDiskSource
import com.x8bit.bitwarden.data.credentials.datasource.disk.entity.PrivilegedAppEntity
import com.x8bit.bitwarden.data.credentials.model.PrivilegedAppAllowListJson
import com.x8bit.bitwarden.data.credentials.repository.model.PrivilegedAppData
import com.x8bit.bitwarden.data.credentials.util.createMockPrivilegedAppJson
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@ -13,9 +20,10 @@ import io.mockk.runs
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertNull
class PrivilegedAppRepositoryTest {
@ -30,12 +38,43 @@ class PrivilegedAppRepositoryTest {
coEvery { removeTrustedPrivilegedApp(any(), any()) } just runs
coEvery { addTrustedPrivilegedApp(any(), any()) } just runs
}
private val mockJson = mockk<Json>()
private val mockPrivilegedAppAllowListJson = mockk<PrivilegedAppAllowListJson>()
private val mockJson = mockk<Json> {
every {
decodeFromString<PrivilegedAppAllowListJson>(any())
} returns mockPrivilegedAppAllowListJson
every {
decodeFromStringOrNull<PrivilegedAppAllowListJson>(any())
} returns mockPrivilegedAppAllowListJson
every { encodeToString(any<PrivilegedAppAllowListJson>()) } returns ALLOW_LIST_JSON
}
private val mockAssetManager = mockk<AssetManager> {
coEvery { readAsset(any()) } returns ALLOW_LIST_JSON.asSuccess()
}
private val mockDispatcherManager = FakeDispatcherManager()
private val repository = PrivilegedAppRepositoryImpl(
privilegedAppDiskSource = mockPrivilegedAppDiskSource,
json = mockJson,
assetManager = mockAssetManager,
dispatcherManager = mockDispatcherManager,
)
@Test
fun `trustedAppDataStateFlow should emit loaded with merged data`() = runTest {
repository.trustedAppDataStateFlow.test {
assertEquals(
DataState.Loaded(
PrivilegedAppData(
googleTrustedApps = mockPrivilegedAppAllowListJson,
communityTrustedApps = mockPrivilegedAppAllowListJson,
userTrustedApps = PrivilegedAppAllowListJson(apps = emptyList()),
),
),
awaitItem(),
)
}
}
@Test
fun `getAllUserTrustedPrivilegedApps should return empty list when disk source is empty`() =
runTest {
@ -43,7 +82,7 @@ class PrivilegedAppRepositoryTest {
mockPrivilegedAppDiskSource.getAllUserTrustedPrivilegedApps()
} returns emptyList()
val result = repository.getAllUserTrustedPrivilegedApps()
val result = repository.getUserTrustedPrivilegedAppsOrNull()
assertTrue(result.apps.isEmpty())
}
@ -59,7 +98,7 @@ class PrivilegedAppRepositoryTest {
mockPrivilegedAppDiskSource.getAllUserTrustedPrivilegedApps()
} returns diskApps
val result = repository.getAllUserTrustedPrivilegedApps()
val result = repository.getUserTrustedPrivilegedAppsOrNull()
assertEquals(
PrivilegedAppAllowListJson(
@ -74,17 +113,18 @@ class PrivilegedAppRepositoryTest {
@Test
fun `userTrustedPrivilegedAppsFlow should emit updates from disk source`() = runTest {
repository.userTrustedPrivilegedAppsFlow.test {
// Verify the initial state is empty
assertEquals(PrivilegedAppAllowListJson(apps = emptyList()), awaitItem())
mutableUserTrustedPrivilegedAppsFlow.emit(
listOf(createMockPrivilegedAppEntity(number = 1)),
)
mutableUserTrustedPrivilegedAppsFlow.emit(
listOf(createMockPrivilegedAppEntity(number = 1)),
)
repository.userTrustedAppsFlow.test {
// Verify the updated state is correct
assertEquals(
PrivilegedAppAllowListJson(apps = listOf(createMockPrivilegedAppJson(number = 1))),
DataState.Loaded(
data = PrivilegedAppAllowListJson(
apps = listOf(createMockPrivilegedAppJson(number = 1)),
),
),
awaitItem(),
)
}
@ -173,6 +213,60 @@ class PrivilegedAppRepositoryTest {
repository.getUserTrustedAllowListJson(),
)
}
@Test
fun `getGoogleTrustedPrivilegedAppsOrNull should return correct data`() = runTest {
assertEquals(
mockPrivilegedAppAllowListJson,
repository.getGoogleTrustedPrivilegedAppsOrNull(),
)
}
@Test
fun `getGoogleTrustedPrivilegedAppsOrNull should return null when asset manager fails`() =
runTest {
coEvery {
mockAssetManager.readAsset(any())
} returns Result.failure(Exception())
assertNull(repository.getGoogleTrustedPrivilegedAppsOrNull())
}
@Test
fun `getGoogleTrustedPrivilegedAppsOrNull should return null when deserialization fails`() =
runTest {
every {
mockJson.decodeFromStringOrNull<PrivilegedAppAllowListJson>(any())
} returns null
assertNull(repository.getGoogleTrustedPrivilegedAppsOrNull())
}
@Test
fun `getCommunityTrustedPrivilegedAppsOrNull should return correct data`() = runTest {
assertEquals(
mockPrivilegedAppAllowListJson,
repository.getCommunityTrustedPrivilegedAppsOrNull(),
)
}
@Test
fun `getCommunityTrustedPrivilegedAppsOrNull should return null when asset manager fails`() =
runTest {
coEvery {
mockAssetManager.readAsset(any())
} returns Result.failure(Exception())
assertNull(repository.getCommunityTrustedPrivilegedAppsOrNull())
}
@Test
fun `getCommunityTrustedPrivilegedAppsOrNull should return null when deserialization fails`() =
runTest {
every {
mockJson.decodeFromStringOrNull<PrivilegedAppAllowListJson>(any())
} returns null
assertNull(repository.getCommunityTrustedPrivilegedAppsOrNull())
}
}
private val ALLOW_LIST_JSON = """
@ -211,16 +305,3 @@ private fun createMockPrivilegedAppEntity(number: Int) = PrivilegedAppEntity(
packageName = "mockPackageName-$number",
signature = "mockSignature-$number",
)
private fun createMockPrivilegedAppJson(number: Int) = PrivilegedAppAllowListJson.PrivilegedAppJson(
type = "android",
info = PrivilegedAppAllowListJson.PrivilegedAppJson.InfoJson(
packageName = "mockPackageName-$number",
signatures = listOf(
PrivilegedAppAllowListJson.PrivilegedAppJson.InfoJson.SignatureJson(
build = "release",
certFingerprintSha256 = "mockSignature-$number",
),
),
),
)

View File

@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.credentials.util
import com.x8bit.bitwarden.data.credentials.model.PrivilegedAppAllowListJson
/**
* Creates a mock PrivilegedAppJson object for testing purposes.
*/
fun createMockPrivilegedAppJson(
number: Int,
type: String = "android",
packageName: String = "mockPackageName-$number",
signatures: List<PrivilegedAppAllowListJson.PrivilegedAppJson.InfoJson.SignatureJson> = listOf(
PrivilegedAppAllowListJson.PrivilegedAppJson.InfoJson.SignatureJson(
build = "release",
certFingerprintSha256 = "mockSignature-$number",
),
),
) = PrivilegedAppAllowListJson.PrivilegedAppJson(
type = type,
info = PrivilegedAppAllowListJson.PrivilegedAppJson.InfoJson(
packageName = packageName,
signatures = signatures,
),
)

View File

@ -40,6 +40,7 @@ class AutoFillScreenTest : BitwardenComposeTest() {
private var onNavigateToBlockAutoFillScreenCalled = false
private var onNavigateToSetupAutoFillScreenCalled = false
private var onNavigateToAboutPrivilegedAppsScreenCalled = false
private var onNavigateToPrivilegedAppsListCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<AutoFillEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -66,6 +67,9 @@ class AutoFillScreenTest : BitwardenComposeTest() {
onNavigateToAboutPrivilegedAppsScreen = {
onNavigateToAboutPrivilegedAppsScreenCalled = true
},
onNavigateToPrivilegedAppsList = {
onNavigateToPrivilegedAppsListCalled = true
},
viewModel = viewModel,
)
}
@ -627,6 +631,24 @@ class AutoFillScreenTest : BitwardenComposeTest() {
viewModel.trySendAction(AutoFillAction.AboutPrivilegedAppsClick)
}
}
@Test
fun `on NavigateToPrivilegedAppsList should call onNavigateToPrivilegedAppsList`() {
mutableEventFlow.tryEmit(AutoFillEvent.NavigateToPrivilegedAppsListScreen)
assertTrue(onNavigateToPrivilegedAppsListCalled)
}
@Test
fun `privileged apps row click should send PrivilegedAppsClick`() {
mutableStateFlow.update { it.copy(isUserManagedPrivilegedAppsEnabled = true) }
composeTestRule
.onNodeWithText("Privileged apps")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(AutoFillAction.PrivilegedAppsClick)
}
}
}
private val DEFAULT_STATE: AutoFillState = AutoFillState(

View File

@ -434,6 +434,20 @@ class AutoFillViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `when PrivilegedAppsClick action is handled the correct NavigateToPrivilegedAppsListScreen event is sent`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(AutoFillAction.PrivilegedAppsClick)
assertEquals(
AutoFillEvent.NavigateToPrivilegedAppsListScreen,
awaitItem(),
)
}
}
private fun createViewModel(
state: AutoFillState? = DEFAULT_STATE,
): AutoFillViewModel = AutoFillViewModel(

View File

@ -1,11 +1,10 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.about
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.about.AboutPrivilegedAppsScreen
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test

View File

@ -0,0 +1,392 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.list
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
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 com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.list.model.PrivilegedAppListItem
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test
class PrivilegedAppsListScreenTest : BitwardenComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<PrivilegedAppsListEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<PrivilegedAppsListViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
setContent {
PrivilegedAppsListScreen(
onNavigateBack = {
onNavigateBackCalled = true
},
viewModel = viewModel,
)
}
}
@Test
fun `on back click sends BackClick`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify { viewModel.trySendAction(PrivilegedAppsListAction.BackClick) }
}
@Test
fun `BackClick event should navigate back`() {
mutableEventFlow.tryEmit(PrivilegedAppsListEvent.NavigateBack)
assert(onNavigateBackCalled)
}
@Test
fun `dialog is shown based on state`() {
// Verify loading dialog is shown.
mutableStateFlow.value = DEFAULT_STATE.copy(
dialogState = PrivilegedAppsListState.DialogState.Loading,
)
composeTestRule
.onNodeWithText("Loading")
.assert(hasAnyAncestor(isDialog()))
.assertExists()
// Verify general dialog is shown.
mutableStateFlow.value = DEFAULT_STATE.copy(
dialogState = PrivilegedAppsListState.DialogState.General(message = "error".asText()),
)
composeTestRule
.onNodeWithText("error")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
// Verify confirm delete trusted app dialog is shown.
mutableStateFlow.value = DEFAULT_STATE.copy(
dialogState = PrivilegedAppsListState.DialogState.ConfirmDeleteTrustedApp(
createMockPrivilegedAppListItem(number = 1),
),
)
composeTestRule
.onNodeWithText("Are you sure you want to stop trusting mockPackageName-1?")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
// Verify no dialog is shown.
mutableStateFlow.value = DEFAULT_STATE.copy(
dialogState = null,
)
composeTestRule.assertNoDialogExists()
}
@Test
fun `General dialog Okay click sends DismissDialogClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
dialogState = PrivilegedAppsListState.DialogState.General(message = "error".asText()),
)
composeTestRule
.onNodeWithText("Okay")
.performClick()
verify { viewModel.trySendAction(PrivilegedAppsListAction.DismissDialogClick) }
}
@Test
fun `onConfirmDeleteTrustedAppClick sends UserTrustedAppDeleteConfirmClick`() {
val app = createMockPrivilegedAppListItem(number = 1)
mutableStateFlow.value = DEFAULT_STATE.copy(
dialogState = PrivilegedAppsListState.DialogState.ConfirmDeleteTrustedApp(
app = app,
),
)
composeTestRule
.onNodeWithText("Okay")
.performClick()
verify {
viewModel.trySendAction(
PrivilegedAppsListAction.UserTrustedAppDeleteConfirmClick(app),
)
}
}
@Test
fun `ConfirmDeleteTrustedApp dialog Cancel click sends DismissDialogClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
dialogState = PrivilegedAppsListState.DialogState.ConfirmDeleteTrustedApp(
app = createMockPrivilegedAppListItem(number = 1),
),
)
composeTestRule
.onNodeWithText("Cancel")
.performClick()
verify { viewModel.trySendAction(PrivilegedAppsListAction.DismissDialogClick) }
}
@Test
fun `privileged app name displays correctly based on state`() {
val installedApps = persistentListOf(
createMockPrivilegedAppListItem(number = 1),
createMockPrivilegedAppListItem(number = 2).copy(appName = null),
)
mutableStateFlow.value = DEFAULT_STATE.copy(
installedApps = installedApps,
)
// Verify app name is shown if present.
composeTestRule
.onNodeWithText("mockAppName-1 (mockPackageName-1)")
.assertIsDisplayed()
// Verify package name is shown when app name is null.
composeTestRule
.onNodeWithText("mockPackageName-2")
.assertIsDisplayed()
}
@Test
fun `privileged app trust authority displays correctly based on state`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
installedApps = persistentListOf(
createMockPrivilegedAppListItem(number = 1),
),
)
composeTestRule
.onNodeWithText("Trusted by YOU")
.assertIsDisplayed()
mutableStateFlow.value = DEFAULT_STATE.copy(
installedApps = persistentListOf(
createMockPrivilegedAppListItem(number = 1).copy(
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.COMMUNITY,
),
),
)
composeTestRule
.onNodeWithText("Trusted by the Community")
.assertIsDisplayed()
mutableStateFlow.value = DEFAULT_STATE.copy(
installedApps = persistentListOf(
createMockPrivilegedAppListItem(number = 1).copy(
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.GOOGLE,
),
),
)
composeTestRule
.onNodeWithText("Trusted by Google")
.assertIsDisplayed()
}
@Test
fun `Installed apps header is shown based on state`() {
composeTestRule
.onNodeWithText("INSTALLED APPS", substring = true)
.assertDoesNotExist()
mutableStateFlow.value = DEFAULT_STATE.copy(
installedApps = persistentListOf(
createMockPrivilegedAppListItem(number = 1),
),
)
composeTestRule
.onNodeWithText("INSTALLED APPS (1)")
.assertIsDisplayed()
}
@Test
fun `All trusted apps header is shown based on state`() {
composeTestRule
.onNodeWithText("All trusted apps")
.assertDoesNotExist()
mutableStateFlow.value = DEFAULT_STATE.copy(
notInstalledApps = persistentListOf(
createMockPrivilegedAppListItem(number = 1),
),
)
composeTestRule
.onNodeWithText("All trusted apps")
.assertIsDisplayed()
}
@Test
fun `user trusted apps display correctly`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
notInstalledApps = persistentListOf(
createMockPrivilegedAppListItem(number = 1),
),
)
composeTestRule
.onNodeWithText("All trusted apps")
.performClick()
composeTestRule
.onNodeWithText("TRUSTED BY YOU (1)")
.performScrollTo()
.assertIsDisplayed()
mutableStateFlow.value = DEFAULT_STATE.copy(
notInstalledApps = persistentListOf(
createMockPrivilegedAppListItem(number = 1).copy(
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.COMMUNITY,
),
),
)
composeTestRule
.onNodeWithText("TRUSTED BY YOU (1)")
.assertDoesNotExist()
}
@Test
fun `community trusted apps display correctly`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
notInstalledApps = persistentListOf(
createMockPrivilegedAppListItem(
number = 1,
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.COMMUNITY,
),
),
)
composeTestRule
.onNodeWithText("All trusted apps")
.performClick()
composeTestRule
.onNodeWithText("TRUSTED BY THE COMMUNITY (1)")
.assertIsDisplayed()
mutableStateFlow.value = DEFAULT_STATE.copy(
notInstalledApps = persistentListOf(
createMockPrivilegedAppListItem(
number = 1,
),
),
)
composeTestRule
.onNodeWithText("TRUSTED BY THE COMMUNITY (1)")
.assertDoesNotExist()
}
@Test
fun `google trusted apps display correctly`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
notInstalledApps = persistentListOf(
createMockPrivilegedAppListItem(
number = 1,
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.GOOGLE,
),
),
)
composeTestRule
.onNodeWithText("All trusted apps")
.performClick()
composeTestRule
.onNodeWithText("TRUSTED BY GOOGLE (1)")
.assertIsDisplayed()
mutableStateFlow.value = DEFAULT_STATE.copy(
notInstalledApps = persistentListOf(
createMockPrivilegedAppListItem(
number = 1,
),
),
)
composeTestRule
.onNodeWithText("TRUSTED BY GOOGLE (1)")
.assertDoesNotExist()
}
@Test
fun `Delete icon displays based on state`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
installedApps = persistentListOf(
createMockPrivilegedAppListItem(number = 1),
),
)
composeTestRule
.onNodeWithContentDescription("Delete", substring = true)
.assertIsDisplayed()
mutableStateFlow.value = DEFAULT_STATE.copy(
installedApps = persistentListOf(
createMockPrivilegedAppListItem(
number = 1,
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.COMMUNITY,
),
),
)
composeTestRule
.onNodeWithContentDescription("Delete", substring = true)
.assertDoesNotExist()
}
@Test
fun `onDeleteClick sends UserTrustedAppDeleteClick`() {
val app = createMockPrivilegedAppListItem(number = 1)
mutableStateFlow.value = DEFAULT_STATE.copy(
installedApps = persistentListOf(app),
)
composeTestRule
.onNodeWithContentDescription("Delete", substring = true)
.performClick()
verify { viewModel.trySendAction(PrivilegedAppsListAction.UserTrustedAppDeleteClick(app)) }
}
}
private fun createMockPrivilegedAppListItem(
number: Int,
appName: String? = "mockAppName-$number",
packageName: String = "mockPackageName-$number",
signature: String = "mockSignature-$number",
trustAuthority: PrivilegedAppListItem.PrivilegedAppTrustAuthority =
PrivilegedAppListItem.PrivilegedAppTrustAuthority.USER,
): PrivilegedAppListItem = PrivilegedAppListItem(
appName = appName,
packageName = packageName,
signature = signature,
trustAuthority = trustAuthority,
)
private val DEFAULT_STATE = PrivilegedAppsListState(
installedApps = persistentListOf(),
notInstalledApps = persistentListOf(),
dialogState = null,
)

View File

@ -0,0 +1,335 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.list
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.manager.BitwardenPackageManager
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.credentials.model.PrivilegedAppAllowListJson
import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository
import com.x8bit.bitwarden.data.credentials.repository.model.PrivilegedAppData
import com.x8bit.bitwarden.data.credentials.util.createMockPrivilegedAppJson
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.privilegedapps.list.model.PrivilegedAppListItem
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class PrivilegedAppsListViewModelTest : BaseViewModelTest() {
private val mutableTrustedAppDataStateFlow =
MutableStateFlow<DataState<PrivilegedAppData>>(DataState.Loading)
private val mockPrivilegedAppRepository = mockk<PrivilegedAppRepository> {
every { trustedAppDataStateFlow } returns mutableTrustedAppDataStateFlow
coEvery { removeTrustedPrivilegedApp(any(), any()) } just runs
}
private val mockBitwardenPackageManager = mockk<BitwardenPackageManager>()
@Test
fun `initial state should be correct`() {
val viewModel = createViewModel()
assert(
viewModel.stateFlow.value == PrivilegedAppsListState(
installedApps = persistentListOf(),
notInstalledApps = persistentListOf(),
dialogState = PrivilegedAppsListState.DialogState.Loading,
),
)
}
@Test
fun `UserTrustedAppDeleteClick should display confirm delete dialog`() = runTest {
val app = PrivilegedAppListItem(
packageName = "com.example.app",
signature = "signature",
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.COMMUNITY,
appName = "Example App",
)
val state = PrivilegedAppsListState(
installedApps = persistentListOf(app),
notInstalledApps = persistentListOf(),
dialogState = null,
)
val viewModel = createViewModel(state = state)
viewModel.trySendAction(PrivilegedAppsListAction.UserTrustedAppDeleteClick(app))
assertEquals(
state.copy(
dialogState = PrivilegedAppsListState.DialogState.ConfirmDeleteTrustedApp(app),
),
viewModel.stateFlow.value,
)
}
@Test
fun `UserTrustedAppDeleteConfirmClick should show loading dialog then delete app`() = runTest {
val app = PrivilegedAppListItem(
packageName = "com.example.app",
signature = "signature",
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.COMMUNITY,
appName = "Example App",
)
val state = PrivilegedAppsListState(
installedApps = persistentListOf(app),
notInstalledApps = persistentListOf(),
dialogState = PrivilegedAppsListState.DialogState.ConfirmDeleteTrustedApp(app),
)
val viewModel = createViewModel(state = state)
viewModel.trySendAction(PrivilegedAppsListAction.UserTrustedAppDeleteConfirmClick(app))
assert(
viewModel.stateFlow.value == state.copy(
dialogState = PrivilegedAppsListState.DialogState.Loading,
),
)
assertEquals(DataState.Loading, mutableTrustedAppDataStateFlow.value)
}
@Test
fun `DismissDialogClick should hide dialog`() = runTest {
val state = PrivilegedAppsListState(
installedApps = persistentListOf(),
notInstalledApps = persistentListOf(),
dialogState = PrivilegedAppsListState.DialogState.ConfirmDeleteTrustedApp(
PrivilegedAppListItem(
packageName = "com.example.app",
signature = "signature",
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.COMMUNITY,
appName = "Example App",
),
),
)
val viewModel = createViewModel(state = state)
viewModel.trySendAction(PrivilegedAppsListAction.DismissDialogClick)
assertEquals(
state.copy(dialogState = null),
viewModel.stateFlow.value,
)
}
@Test
fun `on back click should send navigate back event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(PrivilegedAppsListAction.BackClick)
assertEquals(PrivilegedAppsListEvent.NavigateBack, awaitItem())
}
}
@Test
fun `on DataState Loaded should update state with data and hide dialog`() = runTest {
every {
mockBitwardenPackageManager.isPackageInstalled(any())
} returns false
every {
mockBitwardenPackageManager.isPackageInstalled("mockPackageName-5")
} returns true
every {
mockBitwardenPackageManager.isPackageInstalled("mockPackageName-6")
} returns true
every {
mockBitwardenPackageManager.getAppLabelForPackageOrNull(any())
} returns null
every {
mockBitwardenPackageManager.getAppLabelForPackageOrNull("mockPackageName-5")
} returns "App 5"
every {
mockBitwardenPackageManager.getAppLabelForPackageOrNull("mockPackageName-6")
} returns "App 6"
val viewModel = createViewModel(state = DEFAULT_STATE)
mutableTrustedAppDataStateFlow.emit(
DataState.Loaded(
PrivilegedAppData(
googleTrustedApps = PrivilegedAppAllowListJson(
apps = listOf(
createMockPrivilegedAppJson(number = 1),
createMockPrivilegedAppJson(number = 2),
),
),
communityTrustedApps = PrivilegedAppAllowListJson(
apps = listOf(
createMockPrivilegedAppJson(number = 3),
createMockPrivilegedAppJson(number = 4),
),
),
userTrustedApps = PrivilegedAppAllowListJson(
apps = listOf(
createMockPrivilegedAppJson(number = 5),
createMockPrivilegedAppJson(number = 6),
),
),
),
),
)
assertEquals(
POPULATED_PRIVILEGED_APPS_LIST_STATE,
viewModel.stateFlow.value,
)
}
@Test
fun `on DataState Loading should show loading dialog`() = runTest {
val viewModel = createViewModel(state = DEFAULT_STATE)
mutableTrustedAppDataStateFlow.emit(DataState.Loading)
assert(
viewModel.stateFlow.value == DEFAULT_STATE.copy(
dialogState = PrivilegedAppsListState.DialogState.Loading,
),
)
}
@Test
fun `onDataState Pending should show data with loading dialog`() = runTest {
every {
mockBitwardenPackageManager.isPackageInstalled(any())
} returns false
every {
mockBitwardenPackageManager.isPackageInstalled("mockPackageName-5")
} returns true
every {
mockBitwardenPackageManager.isPackageInstalled("mockPackageName-6")
} returns true
every {
mockBitwardenPackageManager.getAppLabelForPackageOrNull(any())
} returns null
every {
mockBitwardenPackageManager.getAppLabelForPackageOrNull("mockPackageName-5")
} returns "App 5"
every {
mockBitwardenPackageManager.getAppLabelForPackageOrNull("mockPackageName-6")
} returns "App 6"
val viewModel = createViewModel(state = DEFAULT_STATE)
mutableTrustedAppDataStateFlow.emit(
DataState.Pending(
PrivilegedAppData(
googleTrustedApps = PrivilegedAppAllowListJson(
apps = listOf(
createMockPrivilegedAppJson(number = 1),
createMockPrivilegedAppJson(number = 2),
),
),
communityTrustedApps = PrivilegedAppAllowListJson(
apps = listOf(
createMockPrivilegedAppJson(number = 3),
createMockPrivilegedAppJson(number = 4),
),
),
userTrustedApps = PrivilegedAppAllowListJson(
apps = listOf(
createMockPrivilegedAppJson(number = 5),
createMockPrivilegedAppJson(number = 6),
),
),
),
),
)
assertEquals(
POPULATED_PRIVILEGED_APPS_LIST_STATE
.copy(dialogState = PrivilegedAppsListState.DialogState.Loading),
viewModel.stateFlow.value,
)
}
@Test
fun `on DataState Error should show error dialog`() = runTest {
val viewModel = createViewModel(state = DEFAULT_STATE)
mutableTrustedAppDataStateFlow.emit(DataState.Error(Exception()))
assertEquals(
DEFAULT_STATE.copy(
dialogState = PrivilegedAppsListState.DialogState.General(
message = R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on DataState NoNetwork should display data`() = runTest {
val viewModel = createViewModel(state = DEFAULT_STATE)
mutableTrustedAppDataStateFlow.emit(DataState.NoNetwork())
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
private fun createViewModel(
state: PrivilegedAppsListState? = null,
): PrivilegedAppsListViewModel = PrivilegedAppsListViewModel(
privilegedAppRepository = mockPrivilegedAppRepository,
bitwardenPackageManager = mockBitwardenPackageManager,
savedStateHandle = SavedStateHandle().apply {
set("state", state)
},
)
}
private val POPULATED_PRIVILEGED_APPS_LIST_STATE = PrivilegedAppsListState(
installedApps = persistentListOf(
createMockPrivilegedAppListItem(
number = 5,
appName = "App 5",
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.USER,
),
createMockPrivilegedAppListItem(
number = 6,
appName = "App 6",
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.USER,
),
),
notInstalledApps = persistentListOf(
createMockPrivilegedAppListItem(
number = 1,
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.GOOGLE,
),
createMockPrivilegedAppListItem(
number = 2,
trustAuthority = PrivilegedAppListItem.PrivilegedAppTrustAuthority.GOOGLE,
),
createMockPrivilegedAppListItem(
number = 3,
trustAuthority = PrivilegedAppListItem
.PrivilegedAppTrustAuthority
.COMMUNITY,
),
createMockPrivilegedAppListItem(
number = 4,
trustAuthority = PrivilegedAppListItem
.PrivilegedAppTrustAuthority
.COMMUNITY,
),
),
dialogState = null,
)
private val DEFAULT_STATE = PrivilegedAppsListState(
installedApps = persistentListOf(),
notInstalledApps = persistentListOf(),
dialogState = null,
)
private fun createMockPrivilegedAppListItem(
number: Int = 1,
trustAuthority: PrivilegedAppListItem.PrivilegedAppTrustAuthority =
PrivilegedAppListItem.PrivilegedAppTrustAuthority.COMMUNITY,
appName: String? = null,
packageName: String = "mockPackageName-$number",
signature: String = "mockSignature-$number",
): PrivilegedAppListItem = PrivilegedAppListItem(
packageName = packageName,
signature = signature,
trustAuthority = trustAuthority,
appName = appName,
)

View File

@ -1,6 +1,7 @@
package com.bitwarden.data.manager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
/**
* Primary implementation of [BitwardenPackageManager].
@ -13,7 +14,14 @@ class BitwardenPackageManagerImpl(
override fun isPackageInstalled(packageName: String): Boolean {
return try {
nativePackageManager.getApplicationInfo(packageName, 0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
nativePackageManager.getApplicationInfo(
packageName,
PackageManager.ApplicationInfoFlags.of(0L),
)
} else {
nativePackageManager.getApplicationInfo(packageName, 0)
}
true
} catch (_: PackageManager.NameNotFoundException) {
false

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="21dp"
android:viewportWidth="18"
android:viewportHeight="21">
<path
android:pathData="M7.048,0.226C6.166,0.226 5.383,0.789 5.103,1.625L4.501,3.425H0.975C0.56,3.425 0.225,3.761 0.225,4.175C0.225,4.589 0.56,4.925 0.975,4.925H1.54C1.514,5.012 1.504,5.106 1.513,5.202L2.586,17.23C2.74,18.958 4.188,20.282 5.922,20.282H12.081C13.816,20.282 15.264,18.958 15.418,17.23L16.491,5.202C16.499,5.106 16.489,5.012 16.464,4.925H17.027C17.441,4.925 17.777,4.589 17.777,4.175C17.777,3.761 17.441,3.425 17.027,3.425H13.5L12.899,1.625C12.619,0.789 11.836,0.226 10.954,0.226L7.048,0.226ZM11.919,3.425L11.476,2.101C11.401,1.877 11.191,1.726 10.954,1.726L7.048,1.726C6.811,1.726 6.601,1.877 6.526,2.101L6.083,3.425H11.919ZM2.98,4.925H15.024C15.01,4.971 15.001,5.019 14.997,5.068L13.924,17.097C13.839,18.051 13.039,18.782 12.081,18.782H5.922C4.964,18.782 4.165,18.051 4.08,17.097L3.007,5.068C3.002,5.019 2.993,4.971 2.98,4.925Z"
android:fillColor="#5A6D91"
android:fillType="evenOdd"/>
</vector>