mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 09:56:45 -06:00
[PM-19108] Add Privileged Apps List Screen (#5372)
This commit is contained in:
parent
fbfcfcd683
commit
ddc099f727
@ -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" />
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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() })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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,
|
||||
),
|
||||
)
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
@ -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,
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
@ -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
|
||||
|
||||
10
ui/src/main/res/drawable/ic_delete.xml
Normal file
10
ui/src/main/res/drawable/ic_delete.xml
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user