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 @@ + + +