From ddc099f727af46ed5b008efbcf2eb904434fc9f3 Mon Sep 17 00:00:00 2001
From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
Date: Wed, 25 Jun 2025 12:41:48 -0400
Subject: [PATCH] [PM-19108] Add Privileged Apps List Screen (#5372)
---
app/src/main/AndroidManifest.xml | 5 +
.../di/CredentialProviderModule.kt | 4 +
.../repository/PrivilegedAppRepository.kt | 33 +-
.../repository/PrivilegedAppRepositoryImpl.kt | 120 +++++-
.../repository/model/PrivilegedAppData.kt | 12 +
.../feature/settings/SettingsNavigation.kt | 4 +
.../settings/autofill/AutoFillNavigation.kt | 2 +
.../settings/autofill/AutoFillScreen.kt | 5 +
.../settings/autofill/AutoFillViewModel.kt | 15 +
.../autofill/handlers/AutoFillHandlers.kt | 2 +-
.../list/PrivilegedAppListNavigation.kt | 34 ++
.../list/PrivilegedAppsListScreen.kt | 406 ++++++++++++++++++
.../list/PrivilegedAppsListViewModel.kt | 342 +++++++++++++++
.../list/model/PrivilegedAppListItem.kt | 53 +++
app/src/main/res/values/strings.xml | 9 +
.../repository/PrivilegedAppRepositoryTest.kt | 135 ++++--
.../util/PrivilegedAppListJsonUtils.kt | 24 ++
.../settings/autofill/AutoFillScreenTest.kt | 22 +
.../autofill/AutoFillViewModelTest.kt | 14 +
.../AboutPrivilegedAppsScreenTest.kt | 3 +-
.../list/PrivilegedAppsListScreenTest.kt | 392 +++++++++++++++++
.../list/PrivilegedAppsListViewModelTest.kt | 335 +++++++++++++++
.../manager/BitwardenPackageManagerImpl.kt | 10 +-
ui/src/main/res/drawable/ic_delete.xml | 10 +
24 files changed, 1951 insertions(+), 40 deletions(-)
create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/model/PrivilegedAppData.kt
create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppListNavigation.kt
create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListScreen.kt
create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListViewModel.kt
create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/model/PrivilegedAppListItem.kt
create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/util/PrivilegedAppListJsonUtils.kt
rename app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/{ => about}/AboutPrivilegedAppsScreenTest.kt (95%)
create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListScreenTest.kt
create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListViewModelTest.kt
create mode 100644 ui/src/main/res/drawable/ic_delete.xml
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5c019d11ec..2f53f29ef8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -330,6 +330,11 @@
+
+
+
+
+
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt
index 69089007d5..a1204206f0 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt
@@ -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,
)
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepository.kt
index bcc3808342..21a30f978f 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepository.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepository.kt
@@ -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>
+
/**
* Flow of the user's trusted privileged apps.
*/
- val userTrustedPrivilegedAppsFlow: Flow
+ val userTrustedAppsFlow: StateFlow>
+
+ /**
+ * Flow of the Google's trusted privileged apps.
+ */
+ val googleTrustedPrivilegedAppsFlow: StateFlow>
+
+ /**
+ * Flow of the community's trusted privileged apps.
+ */
+ val communityTrustedAppsFlow: StateFlow>
/**
* 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.
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryImpl.kt
index c48b2e3da4..4ad7d9bd35 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryImpl.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryImpl.kt
@@ -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 =
- 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.Loading)
+ private val mutableGoogleTrustedAppsFlow =
+ MutableStateFlow>(DataState.Loading)
+ private val mutableCommunityTrustedPrivilegedAppsFlow =
+ MutableStateFlow>(DataState.Loading)
+
+ override val trustedAppDataStateFlow: StateFlow> =
+ 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>
+ get() = mutableUserTrustedAppsFlow.asStateFlow()
+
+ override val googleTrustedPrivilegedAppsFlow: StateFlow>
+ get() = mutableGoogleTrustedAppsFlow.asStateFlow()
+
+ override val communityTrustedAppsFlow: StateFlow>
+ get() = mutableCommunityTrustedPrivilegedAppsFlow.asStateFlow()
+
+ init {
+ ioScope.launch {
+ mutableGoogleTrustedAppsFlow.value = assetManager
+ .readAsset(fileName = GOOGLE_ALLOW_LIST_FILE_NAME)
+ .map { json.decodeFromString(it) }
+ .fold(
+ onSuccess = { DataState.Loaded(it) },
+ onFailure = { DataState.Error(it) },
+ )
+
+ mutableCommunityTrustedPrivilegedAppsFlow.value = assetManager
+ .readAsset(fileName = COMMUNITY_ALLOW_LIST_FILE_NAME)
+ .map { json.decodeFromString(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(it) }
+ .getOrNull()
+ }
+
+ override suspend fun getCommunityTrustedPrivilegedAppsOrNull(): PrivilegedAppAllowListJson? {
+ return withContext(ioScope.coroutineContext) {
+ assetManager
+ .readAsset(fileName = COMMUNITY_ALLOW_LIST_FILE_NAME)
+ .map { json.decodeFromStringOrNull(it) }
+ .getOrNull()
+ }
+ }
+
override suspend fun isPrivilegedAppAllowed(
packageName: String,
signature: String,
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/model/PrivilegedAppData.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/model/PrivilegedAppData.kt
new file mode 100644
index 0000000000..e25777f33c
--- /dev/null
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/model/PrivilegedAppData.kt
@@ -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,
+)
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt
index fd350f52ef..ff32edf9b6 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt
@@ -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() })
}
}
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillNavigation.kt
index e36285558b..284bc276ad 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillNavigation.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillNavigation.kt
@@ -22,6 +22,7 @@ fun NavGraphBuilder.autoFillDestination(
onNavigateToBlockAutoFillScreen: () -> Unit,
onNavigateToSetupAutofill: () -> Unit,
onNavigateToAboutPrivilegedAppsScreen: () -> Unit,
+ onNavigateToPrivilegedAppsList: () -> Unit,
) {
composableWithPushTransitions {
AutoFillScreen(
@@ -29,6 +30,7 @@ fun NavGraphBuilder.autoFillDestination(
onNavigateToBlockAutoFillScreen = onNavigateToBlockAutoFillScreen,
onNavigateToSetupAutofill = onNavigateToSetupAutofill,
onNavigateToAboutPrivilegedAppsScreen = onNavigateToAboutPrivilegedAppsScreen,
+ onNavigateToPrivilegedAppsList = onNavigateToPrivilegedAppsList,
)
}
}
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt
index 92ed065938..c74c5f7d6e 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt
@@ -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()
+ }
}
}
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt
index 07f023870d..2649617e76 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt
@@ -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.
*/
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/handlers/AutoFillHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/handlers/AutoFillHandlers.kt
index 26ce5b5be9..475fadea15 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/handlers/AutoFillHandlers.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/handlers/AutoFillHandlers.kt
@@ -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)
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppListNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppListNavigation.kt
new file mode 100644
index 0000000000..fc428eb94c
--- /dev/null
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppListNavigation.kt
@@ -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 {
+ PrivilegedAppsListScreen(onNavigateBack = onNavigateBack)
+ }
+}
+
+/**
+ * Navigate to the privileged apps list screen.
+ */
+fun NavController.navigateToPrivilegedAppsList(navOptions: NavOptions? = null) {
+ navigate(route = PrivilegedAppListRoute, navOptions = navOptions)
+}
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListScreen.kt
new file mode 100644
index 0000000000..092cf2bd71
--- /dev/null
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListScreen.kt
@@ -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
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListViewModel.kt
new file mode 100644
index 0000000000..3b9780f429
--- /dev/null
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListViewModel.kt
@@ -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(
+ 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) {
+ 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,
+ ) {
+ 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,
+ ) {
+ updateViewStateWithData(data = state.data, dialogState = null)
+ }
+
+ private fun handleTrustedAppDataStateLoading() {
+ mutableStateFlow.update {
+ it.copy(dialogState = PrivilegedAppsListState.DialogState.Loading)
+ }
+ }
+
+ private fun handleTrustedAppDataStatePending(
+ state: DataState.Pending,
+ ) {
+ 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()
+ val installedApps = mutableListOf()
+
+ 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.sortIntoInstalledAndNotInstalledCollections(
+ installedApps: MutableList,
+ notInstalledApps: MutableList,
+ ) {
+ 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,
+ val notInstalledApps: ImmutableList,
+ 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,
+ ) : Internal()
+ }
+}
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/model/PrivilegedAppListItem.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/model/PrivilegedAppListItem.kt
new file mode 100644
index 0000000000..4bc43584cc
--- /dev/null
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/model/PrivilegedAppListItem.kt
@@ -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),
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d9110a26cd..32f8d1045f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -982,4 +982,13 @@ Do you want to switch to this account?
Learn more about privileged apps
Privileged apps
Unrecognized browser
+ Learn more about using passkeys with Bitwarden.
+ Google
+ the Community
+ YOU
+ All trusted apps
+ Trusted by %s
+ Are you sure you want to stop trusting %s?
+ Installed apps
+ Delete %s
diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryTest.kt
index 5334f05696..c9d1ea132b 100644
--- a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryTest.kt
+++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryTest.kt
@@ -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()
+ private val mockPrivilegedAppAllowListJson = mockk()
+ private val mockJson = mockk {
+ every {
+ decodeFromString(any())
+ } returns mockPrivilegedAppAllowListJson
+ every {
+ decodeFromStringOrNull(any())
+ } returns mockPrivilegedAppAllowListJson
+ every { encodeToString(any()) } returns ALLOW_LIST_JSON
+ }
+ private val mockAssetManager = mockk {
+ 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(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(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",
- ),
- ),
- ),
-)
diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/util/PrivilegedAppListJsonUtils.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/util/PrivilegedAppListJsonUtils.kt
new file mode 100644
index 0000000000..315c955924
--- /dev/null
+++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/util/PrivilegedAppListJsonUtils.kt
@@ -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 = listOf(
+ PrivilegedAppAllowListJson.PrivilegedAppJson.InfoJson.SignatureJson(
+ build = "release",
+ certFingerprintSha256 = "mockSignature-$number",
+ ),
+ ),
+) = PrivilegedAppAllowListJson.PrivilegedAppJson(
+ type = type,
+ info = PrivilegedAppAllowListJson.PrivilegedAppJson.InfoJson(
+ packageName = packageName,
+ signatures = signatures,
+ ),
+)
diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt
index f5473b8d9c..d4a4518839 100644
--- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt
+++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt
@@ -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()
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(
diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt
index bf1c488509..22be649537 100644
--- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt
+++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt
@@ -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(
diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/AboutPrivilegedAppsScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/about/AboutPrivilegedAppsScreenTest.kt
similarity index 95%
rename from app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/AboutPrivilegedAppsScreenTest.kt
rename to app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/about/AboutPrivilegedAppsScreenTest.kt
index 19f3e4f93b..08756074b0 100644
--- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/AboutPrivilegedAppsScreenTest.kt
+++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/about/AboutPrivilegedAppsScreenTest.kt
@@ -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
diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListScreenTest.kt
new file mode 100644
index 0000000000..1395c88ef8
--- /dev/null
+++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListScreenTest.kt
@@ -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()
+ private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
+ private val viewModel = mockk(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,
+)
diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListViewModelTest.kt
new file mode 100644
index 0000000000..c91823143d
--- /dev/null
+++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/privilegedapps/list/PrivilegedAppsListViewModelTest.kt
@@ -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.Loading)
+ private val mockPrivilegedAppRepository = mockk {
+ every { trustedAppDataStateFlow } returns mutableTrustedAppDataStateFlow
+ coEvery { removeTrustedPrivilegedApp(any(), any()) } just runs
+ }
+ private val mockBitwardenPackageManager = mockk()
+
+ @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,
+)
diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/BitwardenPackageManagerImpl.kt b/data/src/main/kotlin/com/bitwarden/data/manager/BitwardenPackageManagerImpl.kt
index 88893dd76d..0c81f30fdd 100644
--- a/data/src/main/kotlin/com/bitwarden/data/manager/BitwardenPackageManagerImpl.kt
+++ b/data/src/main/kotlin/com/bitwarden/data/manager/BitwardenPackageManagerImpl.kt
@@ -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
diff --git a/ui/src/main/res/drawable/ic_delete.xml b/ui/src/main/res/drawable/ic_delete.xml
new file mode 100644
index 0000000000..fa5b7166e3
--- /dev/null
+++ b/ui/src/main/res/drawable/ic_delete.xml
@@ -0,0 +1,10 @@
+
+
+