feat: Hide accounts from the Main screens #35

This commit is contained in:
Artem Chepurnoy 2024-02-15 16:27:43 +02:00
parent e8bd9b2e74
commit 462c13643a
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
28 changed files with 374 additions and 85 deletions

View File

@ -26,7 +26,9 @@ import androidx.lifecycle.lifecycleScope
import com.artemchep.keyguard.common.model.MasterSession
import com.artemchep.keyguard.common.model.VaultState
import com.artemchep.keyguard.common.usecase.GetCiphers
import com.artemchep.keyguard.common.usecase.GetProfiles
import com.artemchep.keyguard.common.usecase.GetVaultSession
import com.artemchep.keyguard.common.usecase.filterHiddenProfiles
import com.artemchep.keyguard.feature.keyguard.ManualAppScreen
import com.artemchep.keyguard.feature.keyguard.ManualAppScreenOnCreate
import com.artemchep.keyguard.feature.keyguard.ManualAppScreenOnLoading
@ -179,7 +181,14 @@ class PasskeyGetUnlockActivity : BaseActivity(), DIAware {
}
val ciphers = kotlin.run {
val getCiphers = session.di.direct.instance<GetCiphers>()
getCiphers()
val getProfiles = session.di.direct.instance<GetProfiles>()
val ciphersRawFlow = filterHiddenProfiles(
getProfiles = getProfiles,
getCiphers = getCiphers,
filter = null,
)
ciphersRawFlow
.first()
}

View File

@ -65,10 +65,16 @@ class KeyguardAutofillService : AutofillService(), DIAware {
when (session) {
is MasterSession.Key -> {
val getCiphers = session.di.direct.instance<GetCiphers>()
getCiphers()
val getProfiles = session.di.direct.instance<GetProfiles>()
val ciphersRawFlow = filterHiddenProfiles(
getProfiles = getProfiles,
getCiphers = getCiphers,
filter = null,
)
ciphersRawFlow
.map { ciphers ->
ciphers
.filter { it.deletedDate == null }
.filter { !it.deleted }
.right()
}
}

View File

@ -35,7 +35,9 @@ import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.model.MasterSession
import com.artemchep.keyguard.common.usecase.GetCanWrite
import com.artemchep.keyguard.common.usecase.GetCiphers
import com.artemchep.keyguard.common.usecase.GetProfiles
import com.artemchep.keyguard.common.usecase.GetVaultSession
import com.artemchep.keyguard.common.usecase.filterHiddenProfiles
import com.artemchep.keyguard.feature.crashlytics.crashlyticsTap
import com.artemchep.keyguard.platform.recordLog
import kotlinx.coroutines.CoroutineScope
@ -156,11 +158,17 @@ class KeyguardCredentialService : CredentialProviderService(), DIAware {
val cipherHistoryOpenedRepository =
session.di.direct.instance<CipherHistoryOpenedRepository>()
val getCiphers = session.di.direct.instance<GetCiphers>()
val getProfiles = session.di.direct.instance<GetProfiles>()
val ciphers = getCiphers()
val ciphersRawFlow = filterHiddenProfiles(
getProfiles = getProfiles,
getCiphers = getCiphers,
filter = null,
)
val ciphers = ciphersRawFlow
.map { ciphers ->
ciphers
.filter { it.deletedDate == null }
.filter { !it.deleted }
}
.first()
val response = ioEffect {

View File

@ -14,6 +14,11 @@ data class DProfile(
val accentColor: AccentColors,
val name: String,
val premium: Boolean,
/**
* `true` if the account should be hidden from the main screens and
* not used during the autofill process, `false` otherwise.
*/
val hidden: Boolean,
val securityStamp: String?,
val twoFactorEnabled: Boolean,
val masterPasswordHint: String?,

View File

@ -0,0 +1,5 @@
package com.artemchep.keyguard.common.model
data class PutProfileHiddenRequest(
val patch: Map<String, Boolean>,
)

View File

@ -1,10 +1,86 @@
package com.artemchep.keyguard.common.usecase
import com.artemchep.keyguard.common.model.DFilter
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.common.model.DSend
import com.artemchep.keyguard.common.model.DSendFilter
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Provides a list of all available on the
* device accounts.
*/
interface GetCiphers : () -> Flow<List<DSecret>>
fun filterHiddenProfiles(
getCiphers: GetCiphers,
getProfiles: GetProfiles,
filter: DFilter? = null,
): Flow<List<DSecret>> {
val shouldFilter = filter
?.let { f ->
// If we have a pre-set filter that specifies an
// identifier of the folder or collection or account etc,
// then we ignore the hidden account setting.
DFilter.findOne<DFilter.ById>(f) { true } == null
}
?: true
return if (shouldFilter) {
_filterHiddenProfiles(
getItems = getCiphers,
getProfiles = getProfiles,
getAccount = { it.accountId },
)
} else {
getCiphers()
}
}
fun filterHiddenProfiles(
getSends: GetSends,
getProfiles: GetProfiles,
filter: DSendFilter? = null,
): Flow<List<DSend>> {
val shouldFilter = filter
?.let { f ->
// If we have a pre-set filter that specifies an
// identifier of the folder or collection or account etc,
// then we ignore the hidden account setting.
DSendFilter.findOne<DSendFilter.ById>(f) { true } == null
}
?: true
return if (shouldFilter) {
_filterHiddenProfiles(
getItems = getSends,
getProfiles = getProfiles,
getAccount = { it.accountId },
)
} else {
getSends()
}
}
private inline fun <T> _filterHiddenProfiles(
getItems: () -> Flow<List<T>>,
getProfiles: GetProfiles,
crossinline getAccount: (T) -> String,
): Flow<List<T>> {
val ccc = getProfiles()
.map { profiles ->
profiles
.asSequence()
.filter { it.hidden }
.map { it.accountId }
.toPersistentSet()
}
.distinctUntilChanged()
return getItems()
.combine(ccc) { items, hiddenAccountIds ->
items
.filter { getAccount(it) !in hiddenAccountIds }
}
}

View File

@ -0,0 +1,8 @@
package com.artemchep.keyguard.common.usecase
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.model.PutProfileHiddenRequest
interface PutProfileHidden : (
PutProfileHiddenRequest,
) -> IO<Boolean>

View File

@ -87,6 +87,7 @@ import com.artemchep.keyguard.common.usecase.PatchWatchtowerAlertCipher
import com.artemchep.keyguard.common.usecase.PutAccountColorById
import com.artemchep.keyguard.common.usecase.PutAccountMasterPasswordHintById
import com.artemchep.keyguard.common.usecase.PutAccountNameById
import com.artemchep.keyguard.common.usecase.PutProfileHidden
import com.artemchep.keyguard.common.usecase.RePromptCipherById
import com.artemchep.keyguard.common.usecase.RemoveAccountById
import com.artemchep.keyguard.common.usecase.RemoveAccounts
@ -187,6 +188,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.PatchWatchtowerAlertCip
import com.artemchep.keyguard.provider.bitwarden.usecase.PutAccountColorByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.PutAccountMasterPasswordHintByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.PutAccountNameByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.PutProfileHiddenImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.RePromptCipherByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveAccountByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveAccountsImpl
@ -417,6 +419,9 @@ fun DI.Builder.createSubDi2(
bindSingleton<PutAccountNameById> {
PutAccountNameByIdImpl(this)
}
bindSingleton<PutProfileHidden> {
PutProfileHiddenImpl(this)
}
bindSingleton<GetGeneratorHistory> {
GetGeneratorHistoryImpl(
directDI = this,

View File

@ -21,6 +21,8 @@ data class BitwardenProfile(
val email: String,
val emailVerified: Boolean,
val premium: Boolean,
// Keyguard-specific field
val hidden: Boolean = false,
val securityStamp: String = "",
val twoFactorEnabled: Boolean,
val masterPasswordHint: String?,

View File

@ -5,7 +5,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import arrow.optics.optics
import com.artemchep.keyguard.common.model.DAccount
import com.artemchep.keyguard.feature.home.vault.model.VaultViewItem
import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.ContextItem
@Immutable
@optics
@ -26,7 +26,7 @@ data class AccountViewState(
data class Data(
val data: DAccount,
val items: List<VaultViewItem>,
val actions: List<FlatItemAction>,
val actions: List<ContextItem>,
val primaryAction: PrimaryAction? = null,
val onOpenWebVault: (() -> Unit)? = null,
) : Content {

View File

@ -11,14 +11,18 @@ import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Fingerprint
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material.icons.outlined.HideSource
import androidx.compose.material.icons.outlined.Keyboard
import androidx.compose.material.icons.outlined.Login
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.VerifiedUser
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -36,6 +40,7 @@ import com.artemchep.keyguard.common.model.DAccount
import com.artemchep.keyguard.common.model.DFilter
import com.artemchep.keyguard.common.model.DMeta
import com.artemchep.keyguard.common.model.DProfile
import com.artemchep.keyguard.common.model.PutProfileHiddenRequest
import com.artemchep.keyguard.common.model.firstOrNull
import com.artemchep.keyguard.common.service.clipboard.ClipboardService
import com.artemchep.keyguard.common.usecase.CopyText
@ -52,6 +57,7 @@ import com.artemchep.keyguard.common.usecase.GetProfiles
import com.artemchep.keyguard.common.usecase.PutAccountColorById
import com.artemchep.keyguard.common.usecase.PutAccountMasterPasswordHintById
import com.artemchep.keyguard.common.usecase.PutAccountNameById
import com.artemchep.keyguard.common.usecase.PutProfileHidden
import com.artemchep.keyguard.common.usecase.QueueSyncById
import com.artemchep.keyguard.common.usecase.RemoveAccountById
import com.artemchep.keyguard.common.usecase.SupervisorRead
@ -117,6 +123,7 @@ fun accountState(
putAccountNameById = instance(),
putAccountColorById = instance(),
putAccountMasterPasswordHintById = instance(),
putProfileHidden = instance(),
getAccounts = instance(),
getProfiles = instance(),
getCiphers = instance(),
@ -148,6 +155,7 @@ fun accountState(
putAccountNameById: PutAccountNameById,
putAccountColorById: PutAccountColorById,
putAccountMasterPasswordHintById: PutAccountMasterPasswordHintById,
putProfileHidden: PutProfileHidden,
getAccounts: GetAccounts,
getProfiles: GetProfiles,
getCiphers: GetCiphers,
@ -203,6 +211,16 @@ fun accountState(
navigate(intent)
}
fun doHideProfileById(profileId: String, hidden: Boolean) {
val request = PutProfileHiddenRequest(
patch = mapOf(
profileId to hidden,
),
)
putProfileHidden(request)
.launchIn(appScope)
}
val accountFlow = getAccounts()
.map { it.firstOrNull(accountId) }
launchAutoPopSelfHandler(accountFlow)
@ -317,48 +335,88 @@ fun accountState(
getGravatarUrl = getGravatarUrl,
).toList()
}
val actionsFlow = combine(
accountFlow,
profileFlow,
) { accountOrNull, profileOrNull ->
val syncing = false
val busy = false
buildContextItems {
section {
if (accountOrNull == null) {
return@section
}
this += FlatItemAction(
leading = {
SyncIcon(rotating = syncing)
},
title = translate(Res.strings.sync),
onClick = if (busy) {
null
} else {
// on click listener
::doSyncAccountById.partially1(accountOrNull.id)
},
)
}
section {
if (profileOrNull == null) {
return@section
}
val hidden = profileOrNull.hidden
val onCheckedChange = ::doHideProfileById
.partially1(profileOrNull.profileId)
this += FlatItemAction(
leading = {
Icon(
Icons.Outlined.VisibilityOff,
null,
)
},
trailing = {
Switch(
checked = hidden,
onCheckedChange = onCheckedChange,
)
},
title = translate(Res.strings.account_action_hide_title),
text = translate(Res.strings.account_action_hide_text),
onClick = onCheckedChange.partially1(!hidden),
)
}
section {
if (accountOrNull == null) {
return@section
}
this += FlatItemAction(
icon = Icons.Outlined.Logout,
title = translate(Res.strings.account_action_sign_out_title),
onClick = if (busy) {
null
} else {
// on click listener
::doRemoveAccountById.partially1(accountOrNull.id)
},
)
}
}
}
combine(
accountFlow,
actionsFlow,
itemsFlow,
primaryActionFlow,
) { accountOrNull, items, primaryAction ->
) { accountOrNull, actions, items, primaryAction ->
if (accountOrNull == null) {
return@combine AccountViewState.Content.NotFound
}
val syncing = false
val busy = false
val actionSync =
FlatItemAction(
leading = {
SyncIcon(rotating = syncing)
},
title = translate(Res.strings.sync),
onClick = if (busy) {
null
} else {
// on click listener
::doSyncAccountById.partially1(accountOrNull.id)
},
)
val actionRemove =
FlatItemAction(
icon = Icons.Outlined.Logout,
title = translate(Res.strings.account_action_sign_out_title),
onClick = if (busy) {
null
} else {
// on click listener
::doRemoveAccountById.partially1(accountOrNull.id)
},
)
AccountViewState.Content.Data(
data = accountOrNull,
actions = listOf(
actionSync,
actionRemove,
),
actions = actions,
items = items,
primaryAction = primaryAction,
onOpenWebVault = {

View File

@ -24,18 +24,17 @@ import com.artemchep.keyguard.common.usecase.GetCiphers
import com.artemchep.keyguard.common.usecase.GetCollections
import com.artemchep.keyguard.common.usecase.GetConcealFields
import com.artemchep.keyguard.common.usecase.GetOrganizations
import com.artemchep.keyguard.common.usecase.GetProfiles
import com.artemchep.keyguard.common.usecase.GetTotpCode
import com.artemchep.keyguard.common.usecase.GetWebsiteIcons
import com.artemchep.keyguard.common.usecase.filterHiddenProfiles
import com.artemchep.keyguard.common.util.flow.persistingStateIn
import com.artemchep.keyguard.feature.attachments.SelectableItemState
import com.artemchep.keyguard.feature.attachments.SelectableItemStateRaw
import com.artemchep.keyguard.feature.confirmation.elevatedaccess.createElevatedAccessDialogIntent
import com.artemchep.keyguard.feature.duplicates.DuplicatesRoute
import com.artemchep.keyguard.feature.generator.history.mapLatestScoped
import com.artemchep.keyguard.feature.generator.wordlist.WordlistsRoute
import com.artemchep.keyguard.feature.home.vault.component.VaultListItem
import com.artemchep.keyguard.feature.home.vault.model.VaultItem2
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
import com.artemchep.keyguard.feature.home.vault.screen.VaultViewRoute
import com.artemchep.keyguard.feature.home.vault.screen.toVaultListItem
import com.artemchep.keyguard.feature.home.vault.screen.verify
@ -70,7 +69,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.kodein.di.DirectDI
import org.kodein.di.compose.localDI
@ -103,6 +101,7 @@ fun produceDuplicatesListState(
getOrganizations = instance(),
getCollections = instance(),
getCiphers = instance(),
getProfiles = instance(),
getCanWrite = instance(),
cipherToolbox = instance(),
cipherDuplicatesCheck = instance(),
@ -121,6 +120,7 @@ fun produceDuplicatesListState(
getOrganizations: GetOrganizations,
getCollections: GetCollections,
getCiphers: GetCiphers,
getProfiles: GetProfiles,
getCanWrite: GetCanWrite,
cipherToolbox: CipherToolbox,
cipherDuplicatesCheck: CipherDuplicatesCheck,
@ -203,7 +203,12 @@ fun produceDuplicatesListState(
if (r == 0) r = a.id.compareTo(b.id)
r
}
val ciphersFlow = getCiphers()
val ciphersRawFlow = filterHiddenProfiles(
getProfiles = getProfiles,
getCiphers = getCiphers,
filter = args.filter,
)
val ciphersFlow = ciphersRawFlow
.map { ciphers ->
ciphers
.filter { it.deletedDate == null }

View File

@ -254,6 +254,7 @@ fun accountListScreenState(
title = AnnotatedString(it.username),
text = it.host,
error = error,
hidden = profile?.hidden == true,
syncing = syncing,
selecting = selectionMode,
actions = listOf(),

View File

@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
@ -32,6 +33,7 @@ import androidx.compose.ui.unit.dp
import com.artemchep.keyguard.feature.home.settings.accounts.model.AccountItem
import com.artemchep.keyguard.feature.home.vault.component.Section
import com.artemchep.keyguard.feature.home.vault.component.rememberSecretAccentColor
import com.artemchep.keyguard.ui.AvatarBadgeIcon
import com.artemchep.keyguard.ui.AvatarBuilder
import com.artemchep.keyguard.ui.ExpandedIfNotEmptyForRow
import com.artemchep.keyguard.ui.FlatItemLayout
@ -85,7 +87,11 @@ fun AccountListItemText(
accent = accent,
active = true,
badge = {
// Do nothing.
if (item.hidden) {
AvatarBadgeIcon(
imageVector = Icons.Outlined.VisibilityOff,
)
}
},
)
},

View File

@ -32,6 +32,7 @@ sealed interface AccountItem {
val title: AnnotatedString,
val text: String?,
val error: Boolean = false,
val hidden: Boolean,
val syncing: Boolean = false,
val selecting: Boolean = false,
val actionNeeded: Boolean,

View File

@ -74,6 +74,7 @@ import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
import com.artemchep.keyguard.feature.localization.textResource
import com.artemchep.keyguard.feature.twopane.LocalHasDetailPane
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.AvatarBadgeIcon
import com.artemchep.keyguard.ui.AvatarBuilder
import com.artemchep.keyguard.ui.DisabledEmphasisAlpha
import com.artemchep.keyguard.ui.DropdownMenuItemFlat
@ -840,30 +841,18 @@ fun AccountListItemTextIcon(
active = item.source.service.remote != null,
badge = {
if (item.favourite) {
Icon(
modifier = Modifier
.size(16.dp)
.padding(1.dp),
AvatarBadgeIcon(
imageVector = Icons.Outlined.KeyguardFavourite,
contentDescription = null,
)
}
if (item.source.reprompt) {
Icon(
modifier = Modifier
.size(16.dp)
.padding(1.dp),
AvatarBadgeIcon(
imageVector = Icons.Outlined.Lock,
contentDescription = null,
)
}
if (item.attachments) {
Icon(
modifier = Modifier
.size(16.dp)
.padding(1.dp),
AvatarBadgeIcon(
imageVector = Icons.Outlined.KeyguardAttachment,
contentDescription = null,
)
}
},

View File

@ -60,6 +60,7 @@ import com.artemchep.keyguard.common.usecase.PasskeyTargetCheck
import com.artemchep.keyguard.common.usecase.QueueSyncAll
import com.artemchep.keyguard.common.usecase.RenameFolderById
import com.artemchep.keyguard.common.usecase.SupervisorRead
import com.artemchep.keyguard.common.usecase.filterHiddenProfiles
import com.artemchep.keyguard.common.util.StringComparatorIgnoreCase
import com.artemchep.keyguard.common.util.flow.EventFlow
import com.artemchep.keyguard.common.util.flow.persistingStateIn
@ -314,7 +315,12 @@ fun vaultListScreenState(
}
val copy = copy(clipboardService)
val ciphersRawFlow = getCiphers()
val ciphersRawFlow = filterHiddenProfiles(
getProfiles = getProfiles,
getCiphers = getCiphers,
filter = args.filter,
)
val querySink = mutablePersistedFlow("query") { "" }
val queryState = mutableComposeState(querySink)
@ -326,7 +332,7 @@ fun vaultListScreenState(
val selectionHandle = selectionHandle("selection")
// Automatically remove selection from ciphers
// that do not exist anymore.
getCiphers()
ciphersRawFlow
.onEach { ciphers ->
val selectedItemIds = selectionHandle.idsFlow.value
val filteredSelectedItemIds = selectedItemIds

View File

@ -20,11 +20,13 @@ import com.artemchep.keyguard.common.usecase.GetConcealFields
import com.artemchep.keyguard.common.usecase.GetFolders
import com.artemchep.keyguard.common.usecase.GetOrganizations
import com.artemchep.keyguard.common.usecase.GetPasswordStrength
import com.artemchep.keyguard.common.usecase.GetProfiles
import com.artemchep.keyguard.common.usecase.GetSuggestions
import com.artemchep.keyguard.common.usecase.GetTotpCode
import com.artemchep.keyguard.common.usecase.GetWebsiteIcons
import com.artemchep.keyguard.common.usecase.QueueSyncAll
import com.artemchep.keyguard.common.usecase.SupervisorRead
import com.artemchep.keyguard.common.usecase.filterHiddenProfiles
import com.artemchep.keyguard.common.util.flow.EventFlow
import com.artemchep.keyguard.feature.attachments.SelectableItemState
import com.artemchep.keyguard.feature.home.vault.model.VaultItem2
@ -58,6 +60,7 @@ fun vaultRecentScreenState(
getAccounts = instance(),
getCanWrite = instance(),
getCiphers = instance(),
getProfiles = instance(),
getFolders = instance(),
getCollections = instance(),
getOrganizations = instance(),
@ -84,6 +87,7 @@ fun vaultRecentScreenState(
getAccounts: GetAccounts,
getCanWrite: GetCanWrite,
getCiphers: GetCiphers,
getProfiles: GetProfiles,
getFolders: GetFolders,
getCollections: GetCollections,
getOrganizations: GetOrganizations,
@ -152,6 +156,11 @@ fun vaultRecentScreenState(
val websiteIcons: Boolean,
)
val ciphersRawFlow = filterHiddenProfiles(
getProfiles = getProfiles,
getCiphers = getCiphers,
filter = null,
)
val configFlow = combine(
getConcealFields(),
getAppIcons(),
@ -182,7 +191,7 @@ fun vaultRecentScreenState(
.map { it.cipherId }
.toSet()
}
.combine(getCiphers()) { recents, ciphers ->
.combine(ciphersRawFlow) { recents, ciphers ->
recents
.mapNotNull { recentId ->
ciphers

View File

@ -21,7 +21,6 @@ import com.artemchep.keyguard.common.model.AccountTask
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.common.model.DSend
import com.artemchep.keyguard.common.model.iconImageVector
import com.artemchep.keyguard.common.model.title
import com.artemchep.keyguard.common.model.titleH
import com.artemchep.keyguard.common.service.clipboard.ClipboardService
import com.artemchep.keyguard.common.usecase.CipherToolbox
@ -30,11 +29,13 @@ import com.artemchep.keyguard.common.usecase.DateFormatter
import com.artemchep.keyguard.common.usecase.GetAccounts
import com.artemchep.keyguard.common.usecase.GetAppIcons
import com.artemchep.keyguard.common.usecase.GetCanWrite
import com.artemchep.keyguard.common.usecase.GetProfiles
import com.artemchep.keyguard.common.usecase.GetSends
import com.artemchep.keyguard.common.usecase.GetSuggestions
import com.artemchep.keyguard.common.usecase.GetWebsiteIcons
import com.artemchep.keyguard.common.usecase.QueueSyncAll
import com.artemchep.keyguard.common.usecase.SupervisorRead
import com.artemchep.keyguard.common.usecase.filterHiddenProfiles
import com.artemchep.keyguard.common.util.flow.EventFlow
import com.artemchep.keyguard.common.util.flow.persistingStateIn
import com.artemchep.keyguard.feature.attachments.SelectableItemState
@ -99,7 +100,6 @@ import org.kodein.di.DirectDI
import org.kodein.di.compose.localDI
import org.kodein.di.direct
import org.kodein.di.instance
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@LeParcelize
@ -151,6 +151,7 @@ fun sendListScreenState(
getAccounts = instance(),
getCanWrite = instance(),
getSends = instance(),
getProfiles = instance(),
getAppIcons = instance(),
getWebsiteIcons = instance(),
toolbox = instance(),
@ -173,6 +174,7 @@ fun sendListScreenState(
getAccounts: GetAccounts,
getCanWrite: GetCanWrite,
getSends: GetSends,
getProfiles: GetProfiles,
getAppIcons: GetAppIcons,
getWebsiteIcons: GetWebsiteIcons,
toolbox: CipherToolbox,
@ -195,15 +197,11 @@ fun sendListScreenState(
}
val copy = copy(clipboardService)
val ciphersRawFlow = getSends()
// .map { l ->
// Log.e("???","lol ciphers")
// (0..100).flatMap { l }.map {
// it.copy(id = UUID.randomUUID().toString())
// }.also {
// Log.d("??? search", "${it.size} items")
// }
// }
val ciphersRawFlow = filterHiddenProfiles(
getProfiles = getProfiles,
getSends = getSends,
filter = null,
)
val querySink = mutablePersistedFlow("query") { "" }
val queryState = mutableComposeState(querySink)
@ -215,7 +213,7 @@ fun sendListScreenState(
val selectionHandle = selectionHandle("selection")
// Automatically remove selection from ciphers
// that do not exist anymore.
getSends()
ciphersRawFlow
.onEach { ciphers ->
val selectedItemIds = selectionHandle.idsFlow.value
val filteredSelectedItemIds = selectedItemIds

View File

@ -70,6 +70,7 @@ import com.artemchep.keyguard.feature.send.view.SendViewRoute
import com.artemchep.keyguard.feature.twopane.LocalHasDetailPane
import com.artemchep.keyguard.feature.twopane.TwoPaneScreen
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.AvatarBadgeIcon
import com.artemchep.keyguard.ui.AvatarBuilder
import com.artemchep.keyguard.ui.CollectedEffect
import com.artemchep.keyguard.ui.Compose
@ -674,12 +675,8 @@ fun AccountListItemTextIcon(
active = active,
badge = {
if (item.hasPassword) {
Icon(
modifier = Modifier
.size(16.dp)
.padding(1.dp),
AvatarBadgeIcon(
imageVector = Icons.Outlined.Key,
contentDescription = null,
)
}
},

View File

@ -16,6 +16,7 @@ import com.artemchep.keyguard.common.usecase.GetCollections
import com.artemchep.keyguard.common.usecase.GetFolders
import com.artemchep.keyguard.common.usecase.GetOrganizations
import com.artemchep.keyguard.common.usecase.GetProfiles
import com.artemchep.keyguard.common.usecase.filterHiddenProfiles
import com.artemchep.keyguard.common.util.flow.persistingStateIn
import com.artemchep.keyguard.feature.crashlytics.crashlyticsMap
import com.artemchep.keyguard.feature.duplicates.DuplicatesRoute
@ -101,7 +102,12 @@ fun produceWatchtowerState(
PersistedStorage.InDisk(disk)
}
val ciphersFlow = getCiphers()
val ciphersRawFlow = filterHiddenProfiles(
getProfiles = getProfiles,
getCiphers = getCiphers,
filter = null,
)
val ciphersFlow = ciphersRawFlow
.map { secrets ->
secrets
.filter { secret -> !secret.deleted }

View File

@ -190,7 +190,11 @@ class SyncEngine(
}
// Insert updated profile.
if (existingProfile?.data_ != newProfile) {
val newMergedProfile = merge(
remote = newProfile,
local = existingProfile?.data_,
)
if (newMergedProfile != existingProfile?.data_) {
profileDao.insert(
profileId = newProfile.profileId,
accountId = newProfile.accountId,

View File

@ -14,6 +14,7 @@ import com.artemchep.keyguard.common.io.parallel
import com.artemchep.keyguard.common.service.logging.LogLevel
import com.artemchep.keyguard.common.usecase.GetPasswordStrength
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
import com.artemchep.keyguard.core.store.bitwarden.BitwardenProfile
import com.artemchep.keyguard.core.store.bitwarden.BitwardenService
import com.artemchep.keyguard.provider.bitwarden.sync.SyncManager
import kotlinx.coroutines.Dispatchers
@ -92,6 +93,16 @@ suspend fun merge(
)
}
suspend fun merge(
remote: BitwardenProfile,
local: BitwardenProfile?,
): BitwardenProfile {
val hidden = local?.hidden == true
return remote.copy(
hidden = hidden,
)
}
interface RemotePutScope<Remote> {
fun updateRemoteModel(remote: Remote)
}

View File

@ -47,6 +47,7 @@ fun BitwardenProfile.toDomain(
accountUrl = accountUrl,
name = name,
premium = premium,
hidden = hidden,
securityStamp = securityStamp,
twoFactorEnabled = twoFactorEnabled,
masterPasswordHint = masterPasswordHint,

View File

@ -0,0 +1,47 @@
package com.artemchep.keyguard.provider.bitwarden.usecase
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.flatten
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.io.map
import com.artemchep.keyguard.common.model.PutProfileHiddenRequest
import com.artemchep.keyguard.common.usecase.PutProfileHidden
import com.artemchep.keyguard.core.store.bitwarden.BitwardenProfile
import com.artemchep.keyguard.core.store.bitwarden.hidden
import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifyProfileById
import org.kodein.di.DirectDI
import org.kodein.di.instance
/**
* @author Artem Chepurnyi
*/
class PutProfileHiddenImpl(
private val modifyProfileById: ModifyProfileById,
) : PutProfileHidden {
constructor(directDI: DirectDI) : this(
modifyProfileById = directDI.instance(),
)
override fun invoke(
request: PutProfileHiddenRequest,
): IO<Boolean> = ioEffect {
val profileIds = request.patch.keys
modifyProfileById(
profileIds,
) { model ->
var new = model
val hidden = request.patch[model.profileId]
?: return@modifyProfileById new
new = new.copy(
data_ = BitwardenProfile.hidden.set(new.data_, hidden),
)
new
}
// Report that we have actually modified the
// profiles.
.map { changedCipherIds ->
true
}
}
.flatten()
}

View File

@ -23,6 +23,7 @@ class ModifyProfileById(
operator fun invoke(
profileIds: Set<String>,
checkIfChanged: Boolean = true,
transform: suspend (Profile) -> Profile,
): IO<Unit> = modifyDatabase { database ->
val dao = database.profileQueries
@ -33,12 +34,20 @@ class ModifyProfileById(
.filter {
it.profileId in profileIds
}
.map { model ->
.mapNotNull { model ->
var new = model
new = transform(new)
// If the profile was not changed, then we do not need to
// update it in the database.
if (checkIfChanged && new == model) {
return@mapNotNull null
}
new
}
require(models.isNotEmpty())
if (models.isEmpty()) {
return@modifyDatabase ModifyDatabase.Result.unit()
}
dao.transaction {
models.forEach { profile ->
dao.insert(

View File

@ -27,6 +27,7 @@ import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -707,3 +708,17 @@ fun AvatarBuilder(
}
}
}
@Composable
fun AvatarBadgeIcon(
modifier: Modifier = Modifier,
imageVector: ImageVector,
) {
Icon(
modifier = modifier
.size(16.dp)
.padding(1.dp),
imageVector = imageVector,
contentDescription = null,
)
}

View File

@ -200,6 +200,8 @@
<string name="account_action_change_master_password_hint_title">Change master password hint</string>
<string name="account_action_sign_out_title">Sign out</string>
<string name="account_action_sign_in_title">Sign in</string>
<string name="account_action_hide_title">Hide items</string>
<string name="account_action_hide_text">Hide the items from the main screens of the app</string>
<string name="account_action_email_verify_instructions_title">Visit Web vault to verify your email address</string>
<string name="account_action_premium_purchase_instructions_title">Upgrade your account to a premium membership and unlock some great additional features</string>
<string name="account_action_tfa_title">Two-factor authentication</string>