feat: Basic Sends editing capabilities

This commit is contained in:
Artem Chepurnoy 2024-03-26 10:49:26 +02:00
parent 91e130674a
commit d00781636d
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
50 changed files with 5121 additions and 2008 deletions

View File

@ -36,7 +36,8 @@ data class DSend(
val notes: String,
val accessCount: Int,
val maxAccessCount: Int? = null,
val password: String? = null,
val hasPassword: Boolean,
val synced: Boolean,
val disabled: Boolean,
val hideEmail: Boolean,
val type: Type,

View File

@ -0,0 +1,20 @@
package com.artemchep.keyguard.common.model
import arrow.core.None
import arrow.core.Option
data class PatchSendRequest(
val patch: Map<String, Data>,
) {
data class Data(
val name: Option<String> = None,
val hideEmail: Option<Boolean> = None,
val disabled: Option<Boolean> = None,
val password: Option<String?> = None,
/**
* Changes the file name of the send.
* Note: only applied to sends with a file type.
*/
val fileName: Option<String> = None,
)
}

View File

@ -0,0 +1,53 @@
package com.artemchep.keyguard.common.model.create
import arrow.optics.optics
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.common.model.DSend
import com.artemchep.keyguard.feature.confirmation.organization.FolderInfo
import com.artemchep.keyguard.platform.LeUri
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@optics
data class CreateSendRequest(
val ownership: Ownership? = null,
val title: String? = null,
val note: String? = null,
val favorite: Boolean? = null,
val reprompt: Boolean? = null,
val uris: PersistentList<DSecret.Uri> = persistentListOf(),
val fido2Credentials: PersistentList<DSecret.Login.Fido2Credentials> = persistentListOf(),
val fields: PersistentList<DSecret.Field> = persistentListOf(),
// types
val type: DSend.Type? = null,
val text: Text = Text(),
val file: File = File(),
// other
val now: Instant,
) {
companion object;
@optics
data class Ownership(
val accountId: String?,
) {
companion object;
}
@optics
data class File(
val text: String? = null,
) {
companion object;
}
@optics
data class Text(
val text: String? = null,
val hidden: Boolean? = null,
) {
companion object;
}
}

View File

@ -0,0 +1,9 @@
package com.artemchep.keyguard.common.usecase
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.model.create.CreateRequest
import com.artemchep.keyguard.common.model.create.CreateSendRequest
interface AddSend : (
Map<String?, CreateSendRequest>,
) -> IO<List<String>>

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.PatchSendRequest
interface PatchSendById : (
PatchSendRequest,
) -> IO<Boolean>

View File

@ -0,0 +1,7 @@
package com.artemchep.keyguard.common.usecase
import com.artemchep.keyguard.common.io.IO
interface RemoveSendById : (
Set<String>,
) -> IO<Unit>

View File

@ -0,0 +1,19 @@
package com.artemchep.keyguard.common.usecase
import org.kodein.di.DirectDI
import org.kodein.di.instance
interface SendToolbox {
val patchSendById: PatchSendById
val removeSendById: RemoveSendById
}
class SendToolboxImpl(
override val patchSendById: PatchSendById,
override val removeSendById: RemoveSendById,
) : SendToolbox {
constructor(directDI: DirectDI) : this(
patchSendById = directDI.instance(),
removeSendById = directDI.instance(),
)
}

View File

@ -10,6 +10,7 @@ import com.artemchep.keyguard.common.usecase.GetAccounts
import com.artemchep.keyguard.common.usecase.GetCiphers
import com.artemchep.keyguard.common.usecase.GetFolders
import com.artemchep.keyguard.common.usecase.GetMetas
import com.artemchep.keyguard.common.usecase.GetSends
import com.artemchep.keyguard.common.util.flow.combineToList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@ -24,6 +25,7 @@ class GetAccountStatusImpl(
private val getMetas: GetMetas,
private val getCiphers: GetCiphers,
private val getFolders: GetFolders,
private val getSends: GetSends,
) : GetAccountStatus {
private val importantPermissions = listOf(
Permission.POST_NOTIFICATIONS,
@ -35,6 +37,7 @@ class GetAccountStatusImpl(
getMetas = directDI.instance(),
getCiphers = directDI.instance(),
getFolders = directDI.instance(),
getSends = directDI.instance(),
)
override fun invoke(): Flow<DAccountStatus> {
@ -65,7 +68,11 @@ class GetAccountStatusImpl(
.map {
it.count { !it.synced }
}
combine(c, f) { a, b -> a + b }
val s = getSends()
.map {
it.count { !it.synced }
}
combine(c, f, s) { a, b, s -> a + b + s }
}
val pendingPermissionsFlow = importantPermissions

View File

@ -26,12 +26,15 @@ class GetEnvSendUrlImpl(
): IO<String> = tokenRepository
.getById(AccountId(send.accountId))
.effectMap { token ->
val sendBaseUrl = token?.env?.back()?.buildSendUrl()
requireNotNull(sendBaseUrl) { "Failed to get base send url." }
val baseUrl = token?.env?.back()?.buildSendUrl()
requireNotNull(baseUrl) { "Failed to get base send url." }
val accessId = send.accessId.takeIf { it.isNotEmpty() }
requireNotNull(accessId) { "Failed to get access id." }
val sendUrl = buildString {
append(sendBaseUrl)
append(send.accessId)
append(baseUrl)
append(accessId)
append('/')
val key = send.keyBase64

View File

@ -44,6 +44,7 @@ import com.artemchep.keyguard.common.usecase.AddEmailRelay
import com.artemchep.keyguard.common.usecase.AddFolder
import com.artemchep.keyguard.common.usecase.AddGeneratorHistory
import com.artemchep.keyguard.common.usecase.AddPasskeyCipher
import com.artemchep.keyguard.common.usecase.AddSend
import com.artemchep.keyguard.common.usecase.AddUriCipher
import com.artemchep.keyguard.common.usecase.AddUrlOverride
import com.artemchep.keyguard.common.usecase.ChangeCipherNameById
@ -94,6 +95,7 @@ import com.artemchep.keyguard.common.usecase.GetUrlOverrides
import com.artemchep.keyguard.common.usecase.GetWordlistPrimitive
import com.artemchep.keyguard.common.usecase.MergeFolderById
import com.artemchep.keyguard.common.usecase.MoveCipherToFolderById
import com.artemchep.keyguard.common.usecase.PatchSendById
import com.artemchep.keyguard.common.usecase.PatchWatchtowerAlertCipher
import com.artemchep.keyguard.common.usecase.PutAccountColorById
import com.artemchep.keyguard.common.usecase.PutAccountMasterPasswordHintById
@ -108,11 +110,14 @@ import com.artemchep.keyguard.common.usecase.RemoveEmailRelayById
import com.artemchep.keyguard.common.usecase.RemoveFolderById
import com.artemchep.keyguard.common.usecase.RemoveGeneratorHistory
import com.artemchep.keyguard.common.usecase.RemoveGeneratorHistoryById
import com.artemchep.keyguard.common.usecase.RemoveSendById
import com.artemchep.keyguard.common.usecase.RemoveUrlOverrideById
import com.artemchep.keyguard.common.usecase.RenameFolderById
import com.artemchep.keyguard.common.usecase.RestoreCipherById
import com.artemchep.keyguard.common.usecase.RetryCipher
import com.artemchep.keyguard.common.usecase.RotateDeviceIdUseCase
import com.artemchep.keyguard.common.usecase.SendToolbox
import com.artemchep.keyguard.common.usecase.SendToolboxImpl
import com.artemchep.keyguard.common.usecase.SupervisorRead
import com.artemchep.keyguard.common.usecase.SyncAll
import com.artemchep.keyguard.common.usecase.SyncById
@ -156,6 +161,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.AddCipherUsedAutofillHi
import com.artemchep.keyguard.provider.bitwarden.usecase.AddCipherUsedPasskeyHistoryImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.AddFolderImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.AddPasskeyCipherImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.AddSendImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.AddUriCipherImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.ChangeCipherNameByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.ChangeCipherPasswordByIdImpl
@ -196,6 +202,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.GetUrlOverridesImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.GetWordlistPrimitiveImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.MergeFolderByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.MoveCipherToFolderByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.PatchSendByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.PatchWatchtowerAlertCipherImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.PutAccountColorByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.PutAccountMasterPasswordHintByIdImpl
@ -208,6 +215,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveCipherByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveWordlistByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveEmailRelayByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveFolderByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveSendByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveUrlOverrideByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.RenameFolderByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.RestoreCipherByIdImpl
@ -226,6 +234,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifyCipherById
import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifyDatabase
import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifyFolderById
import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifyProfileById
import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifySendById
import org.kodein.di.DI
import org.kodein.di.bindSingleton
import org.kodein.di.instance
@ -341,6 +350,9 @@ fun DI.Builder.createSubDi2(
bindSingleton<ModifyCipherById> {
ModifyCipherById(this)
}
bindSingleton<ModifySendById> {
ModifySendById(this)
}
bindSingleton<ModifyFolderById> {
ModifyFolderById(this)
}
@ -365,6 +377,12 @@ fun DI.Builder.createSubDi2(
bindSingleton<RemoveCipherById> {
RemoveCipherByIdImpl(this)
}
bindSingleton<RemoveSendById> {
RemoveSendByIdImpl(this)
}
bindSingleton<PatchSendById> {
PatchSendByIdImpl(this)
}
bindSingleton<RemoveFolderById> {
RemoveFolderByIdImpl(this)
}
@ -389,6 +407,9 @@ fun DI.Builder.createSubDi2(
bindSingleton<CipherToolbox> {
CipherToolboxImpl(this)
}
bindSingleton<SendToolbox> {
SendToolboxImpl(this)
}
bindSingleton<CipherUnsecureUrlCheck> {
CipherUnsecureUrlCheckImpl(this)
}
@ -460,6 +481,9 @@ fun DI.Builder.createSubDi2(
bindSingleton<AddCipher> {
AddCipherImpl(this)
}
bindSingleton<AddSend> {
AddSendImpl(this)
}
bindSingleton<AddCipherOpenedHistory> {
AddCipherOpenedHistoryImpl(this)
}

View File

@ -0,0 +1,24 @@
package com.artemchep.keyguard.core.store.bitwarden
import arrow.optics.optics
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@optics
@Serializable
sealed interface BitwardenOptionalStringNullable {
companion object;
@optics
@Serializable
@SerialName("some")
data class Some(
val value: String?,
) : BitwardenOptionalStringNullable {
companion object;
}
@Serializable
@SerialName("none")
data object None : BitwardenOptionalStringNullable
}

View File

@ -2,6 +2,7 @@ package com.artemchep.keyguard.core.store.bitwarden
import arrow.optics.optics
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@ -33,6 +34,8 @@ data class BitwardenSend(
val type: Type = Type.None,
val file: File? = null,
val text: Text? = null,
// changes
val changes: Changes? = null,
) : BitwardenService.Has<BitwardenSend> {
companion object;
@ -45,6 +48,15 @@ data class BitwardenSend(
Text,
}
@optics
@Serializable
@SerialName("changes")
data class Changes(
val passwordBase64: BitwardenOptionalStringNullable = BitwardenOptionalStringNullable.None,
) {
companion object;
}
//
// Types
//

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,32 @@
package com.artemchep.keyguard.feature.add
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester
import com.artemchep.keyguard.ui.focus.FocusRequester2
class AddScreenScope(
initialFocusRequested: Boolean = false,
) {
val initialFocusRequestedState = mutableStateOf(initialFocusRequested)
@Composable
fun initialFocusRequesterEffect(): FocusRequester2 {
val focusRequester = remember { FocusRequester2() }
// Auto focus the text field
// on launch.
LaunchedEffect(focusRequester) {
var initialFocusRequested by initialFocusRequestedState
if (!initialFocusRequested) {
focusRequester.requestFocus()
// do not request it the second time
initialFocusRequested = true
}
}
return focusRequester
}
}

View File

@ -0,0 +1,299 @@
package com.artemchep.keyguard.feature.add
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.VisualTransformation
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.common.model.TotpToken
import com.artemchep.keyguard.common.model.UsernameVariation2
import com.artemchep.keyguard.common.model.create.CreateRequest
import com.artemchep.keyguard.common.usecase.CopyText
import com.artemchep.keyguard.feature.auth.common.SwitchFieldModel
import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
import com.artemchep.keyguard.ui.ContextItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
sealed interface AddStateItem {
val id: String
interface HasOptions<T> {
val options: ImmutableList<ContextItem>
/**
* Copies the data class replacing the old options with a
* provided ones.
*/
fun withOptions(
options: ImmutableList<ContextItem>,
): T
}
interface HasState<T, Request> {
val state: LocalStateItem<T, Request>
}
@Stable
data class Title<Request>(
override val id: String,
override val state: LocalStateItem<TextFieldModel2, Request>,
) : AddStateItem, HasState<TextFieldModel2, Request>
@Stable
data class Username<Request>(
override val id: String,
override val state: LocalStateItem<State, Request>,
) : AddStateItem, HasState<Username.State, Request> {
data class State(
val value: TextFieldModel2,
val type: UsernameVariation2,
)
}
@Stable
data class Password<Request>(
override val id: String,
override val state: LocalStateItem<TextFieldModel2, Request>,
) : AddStateItem, HasState<TextFieldModel2, Request>
@Stable
data class Text<Request>(
override val id: String,
override val state: LocalStateItem<State, Request>,
) : AddStateItem, HasState<Text.State, Request> {
data class State(
val value: TextFieldModel2,
val label: String? = null,
val singleLine: Boolean = false,
val keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
val visualTransformation: VisualTransformation = VisualTransformation.None,
)
}
data class Suggestion<Request>(
override val id: String,
override val state: LocalStateItem<State, Request>,
) : AddStateItem, HasState<Suggestion.State, Request> {
data class State(
val items: ImmutableList<Item>,
)
data class Item(
val key: String,
val text: String,
val value: String,
val source: String,
val selected: Boolean,
val onClick: (() -> Unit)? = null,
)
}
data class Totp<Request>(
override val id: String,
override val state: LocalStateItem<State, Request>,
) : AddStateItem, HasState<Totp.State, Request> {
data class State(
val copyText: CopyText,
val value: TextFieldModel2,
val totpToken: TotpToken? = null,
)
}
data class Passkey<Request>(
override val id: String,
override val options: ImmutableList<ContextItem> = persistentListOf(),
override val state: LocalStateItem<State, Request>,
) : AddStateItem, HasOptions<Passkey<*>>, HasState<Passkey.State, Request> {
override fun withOptions(
options: ImmutableList<ContextItem>,
) = copy(
options = options,
)
data class State(
val passkey: DSecret.Login.Fido2Credentials?,
)
}
data class Attachment<Request>(
override val id: String,
override val options: ImmutableList<ContextItem> = persistentListOf(),
override val state: LocalStateItem<State, Request>,
) : AddStateItem, HasOptions<Attachment<*>>, HasState<Attachment.State, Request> {
override fun withOptions(
options: ImmutableList<ContextItem>,
) = copy(
options = options,
)
data class State(
val id: String,
val name: TextFieldModel2,
val size: String? = null,
/**
* `true` if the attachment is already uploaded to the server,
* `false` otherwise.
*/
val synced: Boolean,
)
}
data class Url<Request>(
override val id: String,
override val options: ImmutableList<ContextItem> = persistentListOf(),
override val state: LocalStateItem<State, Request>,
) : AddStateItem, HasOptions<Url<*>>, HasState<Url.State, Request> {
override fun withOptions(
options: ImmutableList<ContextItem>,
) = copy(
options = options,
)
data class State(
override val options: ImmutableList<ContextItem> = persistentListOf(),
val text: TextFieldModel2,
val matchType: DSecret.Uri.MatchType? = null,
val matchTypeTitle: String? = null,
) : HasOptions<State> {
override fun withOptions(
options: ImmutableList<ContextItem>,
) = copy(
options = options,
)
}
}
data class Field<Request>(
override val id: String,
override val options: ImmutableList<ContextItem> = persistentListOf(),
override val state: LocalStateItem<State, Request>,
) : AddStateItem, HasOptions<Field<*>>, HasState<Field.State, Request> {
override fun withOptions(
options: ImmutableList<ContextItem>,
) = copy(
options = options,
)
sealed interface State : HasOptions<State> {
data class Text(
override val options: ImmutableList<ContextItem> = persistentListOf(),
val label: TextFieldModel2,
val text: TextFieldModel2,
val hidden: Boolean = false,
) : State {
override fun withOptions(
options: ImmutableList<ContextItem>,
) = copy(
options = options,
)
}
data class Switch(
override val options: ImmutableList<ContextItem> = persistentListOf(),
val checked: Boolean = false,
val onCheckedChange: ((Boolean) -> Unit)? = null,
val label: TextFieldModel2,
) : State {
override fun withOptions(
options: ImmutableList<ContextItem>,
) = copy(
options = options,
)
}
data class LinkedId(
override val options: ImmutableList<ContextItem> = persistentListOf(),
val value: DSecret.Field.LinkedId?,
val actions: ImmutableList<ContextItem>,
val label: TextFieldModel2,
) : State {
override fun withOptions(
options: ImmutableList<ContextItem>,
) = copy(
options = options,
)
}
}
}
data class Note<Request>(
override val id: String,
override val state: LocalStateItem<TextFieldModel2, Request>,
val markdown: Boolean,
) : AddStateItem, HasState<TextFieldModel2, Request>
data class Enum<Request>(
override val id: String,
val icon: ImageVector,
val label: String,
override val state: LocalStateItem<State, Request>,
) : AddStateItem, HasState<Enum.State, Request> {
data class State(
val value: String = "",
val dropdown: ImmutableList<ContextItem> = persistentListOf(),
)
}
data class Switch<Request>(
override val id: String,
val title: String,
val text: String? = null,
override val state: LocalStateItem<SwitchFieldModel, Request>,
) : AddStateItem, HasState<SwitchFieldModel, Request>
data class Section(
override val id: String,
val text: String? = null,
) : AddStateItem
//
// CUSTOM
//
data class DateMonthYear<Request>(
override val id: String,
override val state: LocalStateItem<State, Request>,
val label: String,
) : AddStateItem, HasState<DateMonthYear.State, Request> {
data class State(
val month: TextFieldModel2,
val year: TextFieldModel2,
val onClick: () -> Unit,
)
}
data class DateTime<Request>(
override val id: String,
override val state: LocalStateItem<State, Request>,
val label: String,
) : AddStateItem, HasState<DateTime.State, Request> {
data class State(
val localDate: LocalDate,
val localTime: LocalTime,
val onSelectDate: () -> Unit,
val onSelectTime: () -> Unit,
)
}
//
// Items that may modify the amount of items in the
// list.
//
data class Add(
override val id: String,
val text: String,
val actions: ImmutableList<ContextItem>,
) : AddStateItem
}
@Stable
data class LocalStateItem<T, Request>(
val flow: StateFlow<T>,
val populator: Request.(T) -> Request = { this },
)

View File

@ -0,0 +1,24 @@
package com.artemchep.keyguard.feature.add
import com.artemchep.keyguard.ui.icons.AccentColors
data class AddStateOwnership(
val account: Element? = null,
val organization: Element? = null,
val collection: Element? = null,
val folder: Element? = null,
val onClick: (() -> Unit)? = null,
) {
data class Element(
val readOnly: Boolean,
val items: List<Item> = emptyList(),
) {
data class Item(
val key: String,
val title: String,
val text: String? = null,
val stub: Boolean = false,
val accentColors: AccentColors? = null,
)
}
}

View File

@ -28,11 +28,21 @@ class OrganizationConfirmationRoute(
private const val RAW_RO_ORGANIZATION = 2
private const val RAW_RO_COLLECTION = 4
private const val RAW_RO_FOLDER = 8
private const val RAW_HIDE_ORGANIZATION = 16
private const val RAW_HIDE_COLLECTION = 32
private const val RAW_HIDE_FOLDER = 64
private const val RAW_PREMIUM_ACCOUNT = 128
const val RO_ACCOUNT = RAW_RO_ACCOUNT
const val RO_ORGANIZATION = RAW_RO_ORGANIZATION
const val RO_COLLECTION = RAW_RO_COLLECTION
const val RO_FOLDER = RAW_RO_FOLDER
const val HIDE_ORGANIZATION = RAW_RO_ORGANIZATION or RAW_HIDE_ORGANIZATION
const val HIDE_COLLECTION = RAW_RO_COLLECTION or RAW_HIDE_COLLECTION
const val HIDE_FOLDER = RAW_RO_FOLDER or RAW_HIDE_FOLDER
const val PREMIUM_ACCOUNT = RAW_PREMIUM_ACCOUNT
}
data class Decor(

View File

@ -11,9 +11,9 @@ data class OrganizationConfirmationState(
) {
data class Content(
val accounts: Section,
val organizations: Section,
val collections: Section,
val folders: Section,
val organizations: Section?,
val collections: Section?,
val folders: Section?,
val folderNew: TextFieldModel2?,
) {
data class Section(

View File

@ -38,6 +38,7 @@ import com.artemchep.keyguard.ui.theme.isDark
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 kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
@ -54,7 +55,6 @@ fun organizationConfirmationState(
organizationConfirmationState(
args = args,
transmitter = transmitter,
getAccounts = instance(),
getProfiles = instance(),
getOrganizations = instance(),
getCollections = instance(),
@ -192,7 +192,6 @@ private data class FolderVariant(
fun organizationConfirmationState(
args: OrganizationConfirmationRoute.Args,
transmitter: RouteResultTransmitter<OrganizationConfirmationResult>,
getAccounts: GetAccounts,
getProfiles: GetProfiles,
getOrganizations: GetOrganizations,
getCollections: GetCollections,
@ -211,11 +210,18 @@ fun organizationConfirmationState(
val readOnlyFolder = OrganizationConfirmationRoute.Args.RO_FOLDER in args.flags
val readOnlyCollections = OrganizationConfirmationRoute.Args.RO_COLLECTION in args.flags
val hideOrganization = OrganizationConfirmationRoute.Args.HIDE_ORGANIZATION in args.flags
val hideFolder = OrganizationConfirmationRoute.Args.HIDE_FOLDER in args.flags
val hideCollections = OrganizationConfirmationRoute.Args.HIDE_COLLECTION in args.flags
val premiumAccount = OrganizationConfirmationRoute.Args.PREMIUM_ACCOUNT in args.flags
val accountsFlow = getProfiles()
.map { profiles ->
profiles
.map { profile ->
val enabled = profile.accountId() !in args.blacklistedAccountIds
val enabled = profile.accountId() !in args.blacklistedAccountIds &&
(!premiumAccount || profile.premium)
AccountVariant(
accountId = profile.accountId(),
name = profile.email,
@ -494,7 +500,11 @@ fun organizationConfirmationState(
items = items,
)
}
val itemOrganizationsFlow = combine(
val itemOrganizationsFlow = if (hideOrganization) {
// When we hide the organization UI we just display
// a section as null.
flowOf(null)
} else combine(
organizationsFlow,
selectionFlow
.map { it.organizationId to it.accountId }
@ -527,7 +537,11 @@ fun organizationConfirmationState(
.takeIf { selectedOrganizationId != null },
)
}
val itemCollectionsFlow = combine(
val itemCollectionsFlow = if (hideCollections) {
// When we hide the collection UI we just display
// a section as null.
flowOf(null)
} else combine(
collectionsFlow,
selectionFlow
.map {
@ -577,7 +591,11 @@ fun organizationConfirmationState(
val accountId: String?,
)
val itemFoldersFlow = combine(
val itemFoldersFlow = if (hideFolder) {
// When we hide the collection UI we just display
// a section as null.
flowOf(null)
} else combine(
foldersFlow,
selectionFlow
.map {

View File

@ -1,6 +1,5 @@
package com.artemchep.keyguard.feature.home.vault.add
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
@ -13,6 +12,8 @@ import com.artemchep.keyguard.common.model.TotpToken
import com.artemchep.keyguard.common.model.UsernameVariation2
import com.artemchep.keyguard.common.model.create.CreateRequest
import com.artemchep.keyguard.common.usecase.CopyText
import com.artemchep.keyguard.feature.add.AddStateItem
import com.artemchep.keyguard.feature.add.AddStateOwnership
import com.artemchep.keyguard.feature.auth.common.SwitchFieldModel
import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
import com.artemchep.keyguard.feature.confirmation.organization.FolderInfo
@ -46,11 +47,7 @@ data class AddState(
data class Ownership(
val data: Data,
val account: SaveToElement? = null,
val organization: SaveToElement? = null,
val collection: SaveToElement? = null,
val folder: SaveToElement? = null,
val onClick: (() -> Unit)? = null,
val ui: AddStateOwnership,
) {
data class Data(
val accountId: String?,
@ -65,281 +62,4 @@ data class AddState(
val note: SimpleNote?,
val removeOrigin: SwitchFieldModel,
)
data class SaveToElement(
val readOnly: Boolean,
val items: List<Item> = emptyList(),
) {
data class Item(
val key: String,
val title: String,
val text: String? = null,
val accentColors: AccentColors? = null,
)
}
}
sealed interface AddStateItem {
val id: String
interface HasOptions<T> {
val options: List<FlatItemAction>
/**
* Copies the data class replacing the old options with a
* provided ones.
*/
fun withOptions(
options: List<FlatItemAction>,
): T
}
interface HasDecor {
val decor: Decor
}
interface HasState<T> {
val state: LocalStateItem<T>
}
data class Decor(
val shape: Shape = RectangleShape,
val elevation: Dp = 0.dp,
)
data class Title(
override val id: String,
override val state: LocalStateItem<TextFieldModel2>,
) : AddStateItem, HasState<TextFieldModel2>
data class Username(
override val id: String,
override val state: LocalStateItem<State>,
) : AddStateItem, HasState<Username.State> {
data class State(
val value: TextFieldModel2,
val type: UsernameVariation2,
)
}
data class Password(
override val id: String,
override val state: LocalStateItem<TextFieldModel2>,
) : AddStateItem, HasState<TextFieldModel2>
data class Text(
override val id: String,
override val decor: Decor = Decor(),
override val state: LocalStateItem<State>,
) : AddStateItem, HasDecor, HasState<Text.State> {
data class State(
val value: TextFieldModel2,
val label: String? = null,
val singleLine: Boolean = false,
val keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
val visualTransformation: VisualTransformation = VisualTransformation.None,
)
}
data class Suggestion(
override val id: String,
override val decor: Decor = Decor(),
override val state: LocalStateItem<State>,
) : AddStateItem, HasDecor, HasState<Suggestion.State> {
data class State(
val items: ImmutableList<Item>,
)
data class Item(
val key: String,
val text: String,
val value: String,
val source: String,
val selected: Boolean,
val onClick: (() -> Unit)? = null,
)
}
data class Totp(
override val id: String,
override val state: LocalStateItem<State>,
) : AddStateItem, HasState<Totp.State> {
data class State(
val copyText: CopyText,
val value: TextFieldModel2,
val totpToken: TotpToken? = null,
)
}
data class Passkey(
override val id: String,
override val options: List<FlatItemAction> = emptyList(),
override val state: LocalStateItem<State>,
) : AddStateItem, HasOptions<Passkey>, HasState<Passkey.State> {
override fun withOptions(
options: List<FlatItemAction>,
): Passkey = copy(
options = options,
)
data class State(
val passkey: DSecret.Login.Fido2Credentials?,
)
}
data class Attachment(
override val id: String,
override val options: List<FlatItemAction> = emptyList(),
override val state: LocalStateItem<State>,
) : AddStateItem, HasOptions<Attachment>, HasState<Attachment.State> {
override fun withOptions(options: List<FlatItemAction>): Attachment =
copy(
options = options,
)
data class State(
val id: String,
val name: TextFieldModel2,
val size: String? = null,
/**
* `true` if the attachment is already uploaded to the server,
* `false` otherwise.
*/
val synced: Boolean,
)
}
data class Url(
override val id: String,
override val options: List<FlatItemAction> = emptyList(),
override val state: LocalStateItem<State>,
) : AddStateItem, HasOptions<Url>, HasState<Url.State> {
override fun withOptions(options: List<FlatItemAction>): Url =
copy(
options = options,
)
data class State(
override val options: List<FlatItemAction> = emptyList(),
val text: TextFieldModel2,
val matchType: DSecret.Uri.MatchType? = null,
val matchTypeTitle: String? = null,
) : HasOptions<State> {
override fun withOptions(options: List<FlatItemAction>): State =
copy(
options = options,
)
}
}
data class Field(
override val id: String,
override val options: List<FlatItemAction> = emptyList(),
override val state: LocalStateItem<State>,
) : AddStateItem, HasOptions<Field>, HasState<Field.State> {
override fun withOptions(options: List<FlatItemAction>): Field =
copy(
options = options,
)
sealed interface State : HasOptions<State> {
data class Text(
override val options: List<FlatItemAction> = emptyList(),
val label: TextFieldModel2,
val text: TextFieldModel2,
val hidden: Boolean = false,
) : State {
override fun withOptions(options: List<FlatItemAction>): Text =
copy(
options = options,
)
}
data class Switch(
override val options: List<FlatItemAction> = emptyList(),
val checked: Boolean = false,
val onCheckedChange: ((Boolean) -> Unit)? = null,
val label: TextFieldModel2,
) : State {
override fun withOptions(options: List<FlatItemAction>): Switch =
copy(
options = options,
)
}
data class LinkedId(
override val options: List<FlatItemAction> = emptyList(),
val value: DSecret.Field.LinkedId?,
val actions: List<FlatItemAction>,
val label: TextFieldModel2,
) : State {
override fun withOptions(options: List<FlatItemAction>): LinkedId =
copy(
options = options,
)
}
}
}
data class Note(
override val id: String,
override val state: LocalStateItem<TextFieldModel2>,
val markdown: Boolean,
) : AddStateItem, HasState<TextFieldModel2>
data class Enum(
override val id: String,
val icon: ImageVector,
val label: String,
override val state: LocalStateItem<State>,
) : AddStateItem, HasState<Enum.State> {
data class State(
val value: String = "",
val dropdown: List<FlatItemAction> = emptyList(),
)
}
data class Switch(
override val id: String,
val title: String,
val text: String? = null,
override val state: LocalStateItem<SwitchFieldModel>,
) : AddStateItem, HasState<SwitchFieldModel>
data class Section(
override val id: String,
val text: String? = null,
) : AddStateItem
//
// CUSTOM
//
data class DateMonthYear(
override val id: String,
override val state: LocalStateItem<State>,
val label: String,
) : AddStateItem, HasState<DateMonthYear.State> {
data class State(
val month: TextFieldModel2,
val year: TextFieldModel2,
val onClick: () -> Unit,
)
}
//
// Items that may modify the amount of items in the
// list.
//
data class Add(
override val id: String,
val text: String,
val actions: List<FlatItemAction>,
) : AddStateItem
}
data class LocalStateItem<T>(
val flow: StateFlow<T>,
val populator: CreateRequest.(T) -> CreateRequest = { this },
)

View File

@ -95,6 +95,9 @@ import com.artemchep.keyguard.common.util.flow.combineToList
import com.artemchep.keyguard.common.util.flow.foldAsList
import com.artemchep.keyguard.common.util.flow.persistingStateIn
import com.artemchep.keyguard.common.util.validLuhn
import com.artemchep.keyguard.feature.add.AddStateItem
import com.artemchep.keyguard.feature.add.AddStateOwnership
import com.artemchep.keyguard.feature.add.LocalStateItem
import com.artemchep.keyguard.feature.apppicker.AppPickerResult
import com.artemchep.keyguard.feature.apppicker.AppPickerRoute
import com.artemchep.keyguard.feature.auth.common.SwitchFieldModel
@ -130,6 +133,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.autofill
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.SimpleNote
import com.artemchep.keyguard.ui.buildContextItems
import com.artemchep.keyguard.ui.icons.ChevronIcon
import com.artemchep.keyguard.ui.icons.IconBox
import com.artemchep.keyguard.ui.icons.Stub
@ -458,7 +462,7 @@ fun produceAddScreenState(
val item = AddStateItem.Add(
id = "attachment.add",
text = translate(Res.strings.list_add),
actions = listOf(action),
actions = persistentListOf(action),
)
flowOf(emptyList())
},
@ -522,7 +526,7 @@ fun produceAddScreenState(
id = "reprompt",
title = translate(Res.strings.additem_auth_reprompt_title),
text = translate(Res.strings.additem_auth_reprompt_text),
state = LocalStateItem(
state = LocalStateItem<SwitchFieldModel, CreateRequest>(
flow = kotlin.run {
val sink = mutablePersistedFlow("reprompt") {
args.initialValue?.reprompt
@ -650,7 +654,7 @@ fun produceAddScreenState(
},
)
val titleItem = AddStateItem.Title(
val titleItem = AddStateItem.Title<CreateRequest>(
id = "title",
state = LocalStateItem(
flow = kotlin.run {
@ -707,7 +711,7 @@ fun produceAddScreenState(
.map { items ->
items
.mapNotNull { item ->
val stateHolder = item as? AddStateItem.HasState<Any?>
val stateHolder = item as? AddStateItem.HasState<Any?, CreateRequest>
?: return@mapNotNull null
val state = stateHolder.state
@ -858,7 +862,7 @@ fun produceAddScreenState(
f
}
class AddStateItemAttachmentFactory : Foo2Factory<AddStateItem.Attachment, DSecret.Attachment> {
class AddStateItemAttachmentFactory : Foo2Factory<AddStateItem.Attachment<*>, DSecret.Attachment> {
override val type: String = "attachment"
override fun RememberStateFlowScope.release(key: String) {
@ -868,7 +872,7 @@ class AddStateItemAttachmentFactory : Foo2Factory<AddStateItem.Attachment, DSecr
override fun RememberStateFlowScope.add(
key: String,
initial: DSecret.Attachment?,
): AddStateItem.Attachment {
): AddStateItem.Attachment<CreateRequest> {
val nameKey = "$key.name"
val nameSink = mutablePersistedFlow(nameKey) {
initial?.fileName().orEmpty()
@ -902,7 +906,7 @@ class AddStateItemAttachmentFactory : Foo2Factory<AddStateItem.Attachment, DSecr
synced = initial != null,
),
)
return AddStateItem.Attachment(
return AddStateItem.Attachment<CreateRequest>(
id = key,
state = LocalStateItem(
flow = stateFlow,
@ -916,7 +920,7 @@ class AddStateItemAttachmentFactory : Foo2Factory<AddStateItem.Attachment, DSecr
class AddStateItemUriFactory(
private val cipherUnsecureUrlCheck: CipherUnsecureUrlCheck,
) : Foo2Factory<AddStateItem.Url, DSecret.Uri> {
) : Foo2Factory<AddStateItem.Url<*>, DSecret.Uri> {
override val type: String = "uri"
override fun RememberStateFlowScope.release(key: String) {
@ -927,7 +931,7 @@ class AddStateItemUriFactory(
override fun RememberStateFlowScope.add(
key: String,
initial: DSecret.Uri?,
): AddStateItem.Url {
): AddStateItem.Url<CreateRequest> {
val uriKey = "$key.uri"
val uriSink = mutablePersistedFlow(uriKey) {
initial?.uri.orEmpty()
@ -1018,7 +1022,10 @@ class AddStateItemUriFactory(
}
val actionsFlow = actionsMatchTypeItemFlow
.map {
listOf(it, actionsAppPickerItem)
buildContextItems {
this += it
this += actionsAppPickerItem
}
}
val textFlow = uriSink
@ -1090,7 +1097,7 @@ class AddStateItemUriFactory(
matchType = DSecret.Uri.MatchType.default,
),
)
return AddStateItem.Url(
return AddStateItem.Url<CreateRequest>(
id = key,
state = LocalStateItem(
flow = stateFlow,
@ -1108,7 +1115,7 @@ class AddStateItemUriFactory(
}
class AddStateItemPasskeyFactory(
) : Foo2Factory<AddStateItem.Passkey, DSecret.Login.Fido2Credentials> {
) : Foo2Factory<AddStateItem.Passkey<*>, DSecret.Login.Fido2Credentials> {
@kotlinx.serialization.Serializable
private data class PasskeyHolder(
val data: PasskeyData? = null,
@ -1158,7 +1165,7 @@ class AddStateItemPasskeyFactory(
override fun RememberStateFlowScope.add(
key: String,
initial: DSecret.Login.Fido2Credentials?,
): AddStateItem.Passkey {
): AddStateItem.Passkey<CreateRequest> {
val dataKey = "$key.data"
val dataSink = mutablePersistedFlow(
dataKey,
@ -1203,7 +1210,7 @@ class AddStateItemPasskeyFactory(
passkey = null,
),
)
return AddStateItem.Passkey(
return AddStateItem.Passkey<CreateRequest>(
id = key,
state = LocalStateItem(
flow = stateFlow,
@ -1220,11 +1227,11 @@ class AddStateItemPasskeyFactory(
}
}
abstract class AddStateItemFieldFactory : Foo2Factory<AddStateItem.Field, DSecret.Field> {
abstract class AddStateItemFieldFactory : Foo2Factory<AddStateItem.Field<*>, DSecret.Field> {
fun foo(
key: String,
flow: StateFlow<AddStateItem.Field.State>,
) = AddStateItem.Field(
) = AddStateItem.Field<CreateRequest>(
id = key,
state = LocalStateItem(
flow = flow,
@ -1282,7 +1289,7 @@ class AddStateItemFieldTextFactory : AddStateItemFieldFactory() {
override fun RememberStateFlowScope.add(
key: String,
initial: DSecret.Field?,
): AddStateItem.Field {
): AddStateItem.Field<CreateRequest> {
val labelSink = mutablePersistedFlow("$key.label") {
initial?.name.orEmpty()
}
@ -1342,9 +1349,9 @@ class AddStateItemFieldTextFactory : AddStateItemFieldFactory() {
}
val actionsFlow = actionsConcealItemFlow
.map { concealItem ->
listOf(
concealItem,
)
buildContextItems {
this += concealItem
}
}
val stateFlow = combine(
@ -1386,7 +1393,7 @@ class AddStateItemFieldBooleanFactory : AddStateItemFieldFactory() {
override fun RememberStateFlowScope.add(
key: String,
initial: DSecret.Field?,
): AddStateItem.Field {
): AddStateItem.Field<CreateRequest> {
val labelSink = mutablePersistedFlow("$key.label") {
initial?.name.orEmpty()
}
@ -1441,7 +1448,7 @@ class AddStateItemFieldLinkedIdFactory(
override fun RememberStateFlowScope.add(
key: String,
initial: DSecret.Field?,
): AddStateItem.Field {
): AddStateItem.Field<CreateRequest> {
val labelSink = mutablePersistedFlow("$key.label") {
initial?.name.orEmpty()
}
@ -1488,11 +1495,13 @@ class AddStateItemFieldLinkedIdFactory(
}
val actionsFlow = typeFlow
.map { type ->
actionsAll
.mapNotNull { (itemLinkedId, itemAction) ->
itemAction
.takeIf { itemLinkedId.type == type }
}
buildContextItems {
actionsAll
.forEach { (itemLinkedId, itemAction) ->
this += itemAction
.takeIf { itemLinkedId.type == type }
}
}
}
val stateFlow = combine(
labelFlow,
@ -1511,7 +1520,7 @@ class AddStateItemFieldLinkedIdFactory(
initialValue = AddStateItem.Field.State.LinkedId(
label = TextFieldModel2.empty,
value = null,
actions = emptyList(),
actions = persistentListOf(),
),
)
return foo(
@ -1646,15 +1655,16 @@ private fun <Argument> FieldBakeryScope<Argument>.typeBasedAddItem(
return@map null // hide add item
}
val actions = types
.map { type ->
FlatItemAction(
val actions = buildContextItems {
types.forEach { type ->
this += FlatItemAction(
title = type.name,
onClick = ::add
.partially1(type.type)
.partially1(null),
)
}
}
AddStateItem.Add(
id = "$scope.add",
text = translator.translate(Res.strings.list_add),
@ -1795,35 +1805,35 @@ fun <T, Argument> RememberStateFlowScope.foo(
// Appends list-specific options to be able to reorder
// the list or remove an item.
val options = item.options.toMutableList()
if (index > 0) {
options += FlatItemAction(
icon = Icons.Outlined.ArrowUpward,
title = translate(Res.strings.list_move_up),
onClick = ::moveUp.partially1(entry.key),
val options = buildContextItems(item.options) {
if (index > 0) {
this += FlatItemAction(
icon = Icons.Outlined.ArrowUpward,
title = translate(Res.strings.list_move_up),
onClick = ::moveUp.partially1(entry.key),
)
}
if (index < state.size - 1) {
this += FlatItemAction(
icon = Icons.Outlined.ArrowDownward,
title = translate(Res.strings.list_move_down),
onClick = ::moveDown.partially1(entry.key),
)
}
this += FlatItemAction(
icon = Icons.Outlined.DeleteForever,
title = translate(Res.strings.list_remove),
onClick = {
val intent = createConfirmationDialogIntent(
icon = icon(Icons.Outlined.DeleteForever),
title = translate(Res.strings.list_remove_confirmation_title),
) {
delete(entry.key)
}
navigate(intent)
},
)
}
if (index < state.size - 1) {
options += FlatItemAction(
icon = Icons.Outlined.ArrowDownward,
title = translate(Res.strings.list_move_down),
onClick = ::moveDown.partially1(entry.key),
)
}
options += FlatItemAction(
icon = Icons.Outlined.DeleteForever,
title = translate(Res.strings.list_remove),
onClick = {
val intent = createConfirmationDialogIntent(
icon = icon(Icons.Outlined.DeleteForever),
title = translate(Res.strings.list_remove_confirmation_title),
) {
delete(entry.key)
}
navigate(intent)
},
)
item.withOptions(options)
}
}
@ -1853,7 +1863,7 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
data class Fool<T>(
val value: T,
val element: AddState.SaveToElement?,
val element: AddStateOwnership.Element?,
)
val disk = loadDiskHandle("new_item")
@ -1882,31 +1892,31 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
// Make an account that has the most ciphers a
// default account.
val accountId = ioEffect {
val accountIds = getProfiles().toIO().bind()
.map { it.accountId() }
.toSet()
val accountIds = getProfiles().toIO().bind()
.map { it.accountId() }
.toSet()
fun String.takeIfAccountIdExists() = this
.takeIf { id ->
id in accountIds
}
val lastAccountId = accountIdSink.value?.takeIfAccountIdExists()
if (lastAccountId != null) {
return@ioEffect lastAccountId
fun String.takeIfAccountIdExists() = this
.takeIf { id ->
id in accountIds
}
val ciphers = getCiphers().toIO().bind()
ciphers
.asSequence()
.filter { it.organizationId != null }
.groupBy { it.accountId }
// the one that has the most ciphers
.maxByOrNull { entry -> entry.value.size }
// account id
?.key
?.takeIfAccountIdExists()
}.attempt().bind().getOrNull()
val lastAccountId = accountIdSink.value?.takeIfAccountIdExists()
if (lastAccountId != null) {
return@ioEffect lastAccountId
}
val ciphers = getCiphers().toIO().bind()
ciphers
.asSequence()
.filter { it.organizationId != null }
.groupBy { it.accountId }
// the one that has the most ciphers
.maxByOrNull { entry -> entry.value.size }
// account id
?.key
?.takeIfAccountIdExists()
}.attempt().bind().getOrNull()
AddItemOwnershipData(
accountId = accountId,
@ -1934,11 +1944,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
getProfiles(),
) { accountId, profiles ->
if (accountId == null) {
val item = AddState.SaveToElement.Item(
val item = AddStateOwnership.Element.Item(
key = "account.empty",
title = translate(Res.strings.account_none),
stub = true,
)
val el = AddState.SaveToElement(
val el = AddStateOwnership.Element(
readOnly = ro,
items = listOf(item),
)
@ -1949,12 +1960,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
}
val profileOrNull = profiles
.firstOrNull { it.accountId() == accountId }
val el = AddState.SaveToElement(
val el = AddStateOwnership.Element(
readOnly = ro,
items = listOfNotNull(profileOrNull)
.map { account ->
val key = "account.${account.accountId()}"
AddState.SaveToElement.Item(
AddStateOwnership.Element.Item(
key = key,
title = account.email,
text = account.accountHost,
@ -1975,11 +1986,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
getOrganizations(),
) { organizationId, organizations ->
if (organizationId == null) {
val item = AddState.SaveToElement.Item(
val item = AddStateOwnership.Element.Item(
key = "organization.empty",
title = translate(Res.strings.organization_none),
stub = true,
)
val el = AddState.SaveToElement(
val el = AddStateOwnership.Element(
readOnly = ro,
items = listOf(item),
)
@ -1990,12 +2002,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
}
val organizationOrNull = organizations
.firstOrNull { it.id == organizationId }
val el = AddState.SaveToElement(
val el = AddStateOwnership.Element(
readOnly = ro,
items = listOfNotNull(organizationOrNull)
.map { organization ->
val key = "organization.${organization.id}"
AddState.SaveToElement.Item(
AddStateOwnership.Element.Item(
key = key,
title = organization.name,
)
@ -2023,12 +2035,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
val selectedCollections = collections
.sortedWith(StringComparatorIgnoreCase { it.name })
.filter { it.id in collectionIds }
val el = AddState.SaveToElement(
val el = AddStateOwnership.Element(
readOnly = ro,
items = selectedCollections
.map { collection ->
val key = "collection.${collection.id}"
AddState.SaveToElement.Item(
AddStateOwnership.Element.Item(
key = key,
title = collection.name,
)
@ -2048,11 +2060,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
) { selectedFolder, folders ->
when (selectedFolder) {
is FolderInfo.None -> {
val item = AddState.SaveToElement.Item(
val item = AddStateOwnership.Element.Item(
key = "folder.empty",
title = translate(Res.strings.folder_none),
stub = true,
)
val el = AddState.SaveToElement(
val el = AddStateOwnership.Element(
readOnly = false,
items = listOf(item),
)
@ -2063,11 +2076,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
}
is FolderInfo.New -> {
val item = AddState.SaveToElement.Item(
val item = AddStateOwnership.Element.Item(
key = "folder.new",
title = selectedFolder.name,
stub = true,
)
val el = AddState.SaveToElement(
val el = AddStateOwnership.Element(
readOnly = false,
items = listOf(item),
)
@ -2080,12 +2094,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
is FolderInfo.Id -> {
val selectedFolderOrNull = folders
.firstOrNull { it.id == selectedFolder.id }
val el = AddState.SaveToElement(
val el = AddStateOwnership.Element(
readOnly = false,
items = listOfNotNull(selectedFolderOrNull)
.map { folder ->
val key = "folder.${folder.id}"
AddState.SaveToElement.Item(
AddStateOwnership.Element.Item(
key = key,
title = folder.name,
)
@ -2118,8 +2132,7 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
organizationId = organization.value,
collectionIds = collection.value,
)
AddState.Ownership(
data = data,
val ui = AddStateOwnership(
account = account.element,
organization = organization.element.takeIf { account.value != null },
collection = collection.element.takeIf { account.value != null },
@ -2129,7 +2142,7 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
route = OrganizationConfirmationRoute(
args = OrganizationConfirmationRoute.Args(
decor = OrganizationConfirmationRoute.Args.Decor(
title = "Save to",
title = translate(Res.strings.save_to),
icon = Icons.Outlined.AccountBox,
),
flags = flags,
@ -2154,50 +2167,54 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow(
navigate(intent)
},
)
AddState.Ownership(
data = data,
ui = ui,
)
}
}
data class TmpLogin(
val username: AddStateItem.Username,
val password: AddStateItem.Password,
val totp: AddStateItem.Totp,
val username: AddStateItem.Username<CreateRequest>,
val password: AddStateItem.Password<CreateRequest>,
val totp: AddStateItem.Totp<CreateRequest>,
val items: List<AddStateItem>,
)
data class TmpCard(
val cardholderName: AddStateItem.Text,
val brand: AddStateItem.Text,
val number: AddStateItem.Text,
val fromDate: AddStateItem.DateMonthYear,
val expDate: AddStateItem.DateMonthYear,
val code: AddStateItem.Text,
val cardholderName: AddStateItem.Text<CreateRequest>,
val brand: AddStateItem.Text<CreateRequest>,
val number: AddStateItem.Text<CreateRequest>,
val fromDate: AddStateItem.DateMonthYear<CreateRequest>,
val expDate: AddStateItem.DateMonthYear<CreateRequest>,
val code: AddStateItem.Text<CreateRequest>,
val items: List<AddStateItem>,
)
data class TmpIdentity(
val title: AddStateItem.Text,
val firstName: AddStateItem.Text,
val middleName: AddStateItem.Text,
val lastName: AddStateItem.Text,
val address1: AddStateItem.Text,
val address2: AddStateItem.Text,
val address3: AddStateItem.Text,
val city: AddStateItem.Text,
val state: AddStateItem.Text,
val postalCode: AddStateItem.Text,
val country: AddStateItem.Text,
val company: AddStateItem.Text,
val email: AddStateItem.Text,
val phone: AddStateItem.Text,
val ssn: AddStateItem.Text,
val username: AddStateItem.Text,
val passportNumber: AddStateItem.Text,
val licenseNumber: AddStateItem.Text,
val title: AddStateItem.Text<CreateRequest>,
val firstName: AddStateItem.Text<CreateRequest>,
val middleName: AddStateItem.Text<CreateRequest>,
val lastName: AddStateItem.Text<CreateRequest>,
val address1: AddStateItem.Text<CreateRequest>,
val address2: AddStateItem.Text<CreateRequest>,
val address3: AddStateItem.Text<CreateRequest>,
val city: AddStateItem.Text<CreateRequest>,
val state: AddStateItem.Text<CreateRequest>,
val postalCode: AddStateItem.Text<CreateRequest>,
val country: AddStateItem.Text<CreateRequest>,
val company: AddStateItem.Text<CreateRequest>,
val email: AddStateItem.Text<CreateRequest>,
val phone: AddStateItem.Text<CreateRequest>,
val ssn: AddStateItem.Text<CreateRequest>,
val username: AddStateItem.Text<CreateRequest>,
val passportNumber: AddStateItem.Text<CreateRequest>,
val licenseNumber: AddStateItem.Text<CreateRequest>,
val items: List<AddStateItem>,
)
data class TmpNote(
val note: AddStateItem.Note,
val note: AddStateItem.Note<CreateRequest>,
val items: List<AddStateItem>,
)
@ -2214,7 +2231,7 @@ private suspend fun RememberStateFlowScope.produceLoginState(
key: String,
initialValue: String? = null,
populator: CreateRequest.(AddStateItem.Username.State) -> CreateRequest,
factory: (String, LocalStateItem<AddStateItem.Username.State>) -> Item,
factory: (String, LocalStateItem<AddStateItem.Username.State, CreateRequest>) -> Item,
) = kotlin.run {
val id = "$prefix.$key"
@ -2225,9 +2242,10 @@ private suspend fun RememberStateFlowScope.produceLoginState(
LocalStateItem(
flow = ownershipFlow
.map {
it.account
it.ui.account
?.items
?.asSequence()
?.filter { !it.stub }
?.map { it.title }
?.toPersistentList()
?: persistentListOf()
@ -2262,7 +2280,7 @@ private suspend fun RememberStateFlowScope.produceLoginState(
key: String,
initialValue: String? = null,
populator: CreateRequest.(TextFieldModel2) -> CreateRequest,
factory: (String, LocalStateItem<TextFieldModel2>) -> Item,
factory: (String, LocalStateItem<TextFieldModel2, CreateRequest>) -> Item,
) = kotlin.run {
val id = "$prefix.$key"
@ -2381,7 +2399,7 @@ private suspend fun RememberStateFlowScope.produceLoginState(
totpToken = totp,
)
}
LocalStateItem(
LocalStateItem<AddStateItem.Totp.State, CreateRequest>(
flow = flow
.persistingStateIn(
scope = screenScope,
@ -2444,7 +2462,7 @@ private suspend fun RememberStateFlowScope.produceCardState(
initialMonth: String? = null,
initialYear: String? = null,
populator: CreateRequest.(AddStateItem.DateMonthYear.State) -> CreateRequest,
factory: (String, LocalStateItem<AddStateItem.DateMonthYear.State>) -> Item,
factory: (String, LocalStateItem<AddStateItem.DateMonthYear.State, CreateRequest>) -> Item,
): Item {
val id = "$prefix.$key"
@ -2557,7 +2575,7 @@ private suspend fun RememberStateFlowScope.produceCardState(
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None,
lens: Optional<CreateRequest, String>,
) = createItem(
) = createItem<CreateRequest>(
prefix = prefix,
key = key,
label = label,
@ -2587,7 +2605,7 @@ private suspend fun RememberStateFlowScope.produceCardState(
val sink = mutablePersistedFlow(id) { initialValue.orEmpty() }
val state = mutableComposeState(sink)
val state2 = LocalStateItem(
val state2 = LocalStateItem<AddStateItem.Text.State, CreateRequest>(
flow = sink
.map { value ->
val isValid = kotlin.run {
@ -2899,7 +2917,7 @@ private suspend fun RememberStateFlowScope.produceIdentityState(
autocompleteOptions: ImmutableList<String> = persistentListOf(),
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
lens: Optional<CreateRequest, String>,
) = createItem(
) = createItem<CreateRequest>(
prefix = prefix,
key = key,
label = label,
@ -3255,7 +3273,7 @@ private suspend fun RememberStateFlowScope.produceNoteState(
val sink = mutablePersistedFlow(id) { initialValue.orEmpty() }
val state = mutableComposeState(sink)
val stateItem = LocalStateItem(
val stateItem = LocalStateItem<TextFieldModel2, CreateRequest>(
flow = sink
.map { value ->
val model = TextFieldModel2(
@ -3300,7 +3318,7 @@ private suspend fun RememberStateFlowScope.produceNoteState(
)
}
private suspend fun RememberStateFlowScope.createItem(
suspend fun <Request> RememberStateFlowScope.createItem(
prefix: String,
key: String,
label: String? = null,
@ -3310,7 +3328,7 @@ private suspend fun RememberStateFlowScope.createItem(
singleLine: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None,
populator: CreateRequest.(AddStateItem.Text.State) -> CreateRequest,
populator: Request.(AddStateItem.Text.State) -> Request,
) = createItem(
prefix = prefix,
key = key,
@ -3329,7 +3347,7 @@ private suspend fun RememberStateFlowScope.createItem(
)
}
private suspend fun <Item> RememberStateFlowScope.createItem(
suspend fun <Item, Request> RememberStateFlowScope.createItem(
prefix: String,
key: String,
label: String? = null,
@ -3339,8 +3357,8 @@ private suspend fun <Item> RememberStateFlowScope.createItem(
singleLine: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None,
populator: CreateRequest.(AddStateItem.Text.State) -> CreateRequest,
factory: (String, LocalStateItem<AddStateItem.Text.State>) -> Item,
populator: Request.(AddStateItem.Text.State) -> Request,
factory: (String, LocalStateItem<AddStateItem.Text.State, Request>) -> Item,
): Item {
val id = "$prefix.$key"
@ -3384,7 +3402,7 @@ private suspend fun RememberStateFlowScope.createItem2Txt(
key: String,
args: AddRoute.Args,
getSuggestion: (DSecret) -> String?,
field: AddStateItem.Text,
field: AddStateItem.Text<CreateRequest>,
concealed: Boolean = false,
) = createItem2(
prefix = prefix,
@ -3406,7 +3424,7 @@ private suspend fun RememberStateFlowScope.createItem2(
selectedFlow: Flow<String>,
concealed: Boolean = false,
onClick: (String) -> Unit,
): AddStateItem.Suggestion? {
): AddStateItem.Suggestion<CreateRequest>? {
val finalKey = "$prefix.$key.suggestions"
val suggestions = args.merge
?.ciphers

View File

@ -1,17 +1,18 @@
package com.artemchep.keyguard.feature.home.vault.add.attachment
import com.artemchep.keyguard.common.model.create.CreateRequest
import com.artemchep.keyguard.common.model.create.CreateSendRequest
import com.artemchep.keyguard.common.model.create.attachments
import com.artemchep.keyguard.common.util.flow.persistingStateIn
import com.artemchep.keyguard.feature.add.AddStateItem
import com.artemchep.keyguard.feature.add.LocalStateItem
import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
import com.artemchep.keyguard.feature.home.vault.add.AddStateItem
import com.artemchep.keyguard.feature.home.vault.add.Foo2Factory
import com.artemchep.keyguard.feature.home.vault.add.LocalStateItem
import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
class SkeletonAttachmentItemFactory : Foo2Factory<AddStateItem.Attachment, SkeletonAttachment> {
class SkeletonAttachmentItemFactory : Foo2Factory<AddStateItem.Attachment<*>, SkeletonAttachment> {
override val type: String = "attachment"
override fun RememberStateFlowScope.release(key: String) {
@ -22,7 +23,7 @@ class SkeletonAttachmentItemFactory : Foo2Factory<AddStateItem.Attachment, Skele
override fun RememberStateFlowScope.add(
key: String,
initial: SkeletonAttachment?,
): AddStateItem.Attachment {
): AddStateItem.Attachment<CreateRequest> {
val identitySink = mutablePersistedFlow("$key.identity") {
val identity = initial?.identity
requireNotNull(identity)

View File

@ -681,7 +681,7 @@ fun RememberStateFlowScope.cipherDeleteAction(
route = ConfirmationRoute(
args = ConfirmationRoute.Args(
icon = icon(Icons.Outlined.DeleteForever),
title = "Delete forever?",
title = translate(Res.strings.ciphers_action_delete_confirmation_title),
),
),
) { result ->

View File

@ -44,7 +44,7 @@ suspend fun DSend.toVaultListItem(
groupId = groupId,
revisionDate = revisionDate,
createdDate = createdDate,
hasPassword = password != null,
hasPassword = hasPassword,
hasFile = type == DSend.Type.File,
icon = icon,
type = type.name,

View File

@ -7,8 +7,11 @@ import com.artemchep.keyguard.common.model.DSend
import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
import com.artemchep.keyguard.feature.send.search.SendSortItem
import com.artemchep.keyguard.feature.send.search.filter.SendFilterItem
import com.artemchep.keyguard.ui.ContextItem
import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.Selection
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
@ -23,7 +26,7 @@ data class SendListState(
val clearFilters: (() -> Unit)? = null,
val clearSort: (() -> Unit)? = null,
val showKeyboard: Boolean = false,
val primaryActions: List<FlatItemAction> = emptyList(),
val primaryActions: ImmutableList<ContextItem> = persistentListOf(),
val actions: List<FlatItemAction> = emptyList(),
val content: Content = Content.Skeleton,
val sideEffects: SideEffects = SideEffects(),

View File

@ -34,6 +34,7 @@ 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.SendToolbox
import com.artemchep.keyguard.common.usecase.SupervisorRead
import com.artemchep.keyguard.common.usecase.filterHiddenProfiles
import com.artemchep.keyguard.common.util.flow.EventFlow
@ -59,6 +60,7 @@ import com.artemchep.keyguard.feature.navigation.state.PersistedStorage
import com.artemchep.keyguard.feature.navigation.state.copy
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.feature.search.search.SEARCH_DEBOUNCE
import com.artemchep.keyguard.feature.send.add.SendAddRoute
import com.artemchep.keyguard.feature.send.search.AccessCountSendSort
import com.artemchep.keyguard.feature.send.search.AlphabeticalSendSort
import com.artemchep.keyguard.feature.send.search.LastDeletedSendSort
@ -70,23 +72,28 @@ import com.artemchep.keyguard.feature.send.search.SendSortItem
import com.artemchep.keyguard.feature.send.search.ah
import com.artemchep.keyguard.feature.send.search.createFilter
import com.artemchep.keyguard.feature.send.search.filter.FilterSendHolder
import com.artemchep.keyguard.feature.send.util.SendUtil
import com.artemchep.keyguard.leof
import com.artemchep.keyguard.platform.parcelize.LeParcelable
import com.artemchep.keyguard.platform.parcelize.LeParcelize
import com.artemchep.keyguard.platform.util.isRelease
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.Selection
import com.artemchep.keyguard.ui.buildContextItems
import com.artemchep.keyguard.ui.icons.KeyguardView
import com.artemchep.keyguard.ui.icons.SyncIcon
import com.artemchep.keyguard.ui.icons.icon
import com.artemchep.keyguard.ui.selection.selectionHandle
import dev.icerock.moko.resources.StringResource
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
@ -177,7 +184,7 @@ fun sendListScreenState(
getProfiles: GetProfiles,
getAppIcons: GetAppIcons,
getWebsiteIcons: GetWebsiteIcons,
toolbox: CipherToolbox,
toolbox: SendToolbox,
queueSyncAll: QueueSyncAll,
syncSupervisor: SupervisorRead,
dateFormatter: DateFormatter,
@ -227,6 +234,11 @@ fun sendListScreenState(
}
.launchIn(this)
val canEditFlow = SendUtil.canEditFlow(
profilesFlow = getProfiles(),
canWriteFlow = getCanWrite(),
)
val showKeyboardSink = if (args.canAlwaysShowKeyboard) {
mutablePersistedFlow(
key = "keyboard",
@ -677,7 +689,50 @@ fun sendListScreenState(
)
.stateIn(this, SharingStarted.WhileSubscribed(), OurFilterResult())
val selectionFlow = flowOf<Selection?>(null)
fun createTypeAction(
type: DSend.Type,
) = FlatItemAction(
leading = icon(type.iconImageVector()),
title = translate(type.titleH()),
onClick = {
val route = SendAddRoute(
args = SendAddRoute.Args(
type = type,
),
)
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
)
val primaryActionsAll = buildContextItems {
this += createTypeAction(
type = DSend.Type.Text,
)
this += createTypeAction(
type = DSend.Type.File,
)
}
val primaryActionsFlow = kotlin.run {
combine(
canEditFlow,
selectionHandle.idsFlow,
) { canEdit, selectedItemIds ->
if (canEdit && selectedItemIds.isEmpty() && !isRelease) {
primaryActionsAll
} else {
// No items
persistentListOf()
}
}
}
val selectionFlow = SendUtil.selectionFlow(
selectionHandle = selectionHandle,
sendsFlow = ciphersRawFlow,
canEditFlow = canEditFlow,
toolbox = toolbox,
)
val itemsFlow = ciphersFilteredFlow
.combine(selectionFlow) { ciphers, selection ->
@ -792,49 +847,6 @@ fun sendListScreenState(
)
}
fun createTypeAction(
type: DSecret.Type,
) = FlatItemAction(
leading = icon(type.iconImageVector()),
title = translate(type.titleH()),
onClick = {
val autofill = when (mode) {
is AppMode.Main -> null
is AppMode.PickPasskey -> null
is AppMode.SavePasskey -> null
is AppMode.Save -> {
AddRoute.Args.Autofill.leof(mode.args)
}
is AppMode.Pick -> {
AddRoute.Args.Autofill.leof(mode.args)
}
}
val route = LeAddRoute(
args = AddRoute.Args(
type = type,
autofill = autofill,
),
)
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
)
val primaryActions = listOf(
createTypeAction(
type = DSecret.Type.Login,
),
createTypeAction(
type = DSecret.Type.Card,
),
createTypeAction(
type = DSecret.Type.Identity,
),
createTypeAction(
type = DSecret.Type.SecureNote,
),
)
SendListState(
revision = revision,
query = queryField,
@ -842,7 +854,6 @@ fun sendListScreenState(
sort = comparators
.takeIf { queryTrimmed.isEmpty() }
.orEmpty(),
primaryActions = primaryActions,
saveFilters = null,
clearFilters = filters.onClear,
clearSort = if (sortDefault != sort) {
@ -855,16 +866,9 @@ fun sendListScreenState(
content = content,
sideEffects = SendListState.SideEffects(cipherSink),
)
}.combine(
combine(
getCanWrite(),
selectionHandle.idsFlow,
) { canWrite, itemIds ->
canWrite && itemIds.isEmpty()
},
) { state, canWrite ->
}.combine(primaryActionsFlow) { state, actions ->
state.copy(
primaryActions = state.primaryActions.takeIf { canWrite }.orEmpty(),
primaryActions = actions,
)
}.combine(actionsFlow) { state, actions ->
state.copy(

View File

@ -0,0 +1,24 @@
package com.artemchep.keyguard.feature.send.add
import androidx.compose.runtime.Composable
import com.artemchep.keyguard.common.model.DSend
import com.artemchep.keyguard.feature.navigation.Route
class SendAddRoute(
val args: Args,
) : Route {
data class Args(
val type: DSend.Type,
val name: String? = null,
val text: String? = null,
val initialValue: DSend? = null,
val ownershipRo: Boolean = initialValue != null,
)
@Composable
override fun Content() {
SendAddScreen(
args = args,
)
}
}

View File

@ -0,0 +1,204 @@
package com.artemchep.keyguard.feature.send.add
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.model.fold
import com.artemchep.keyguard.common.model.getOrNull
import com.artemchep.keyguard.feature.add.AddScreenItems
import com.artemchep.keyguard.feature.add.AddScreenScope
import com.artemchep.keyguard.feature.add.ToolbarContent
import com.artemchep.keyguard.feature.add.ToolbarContentItemErrSkeleton
import com.artemchep.keyguard.feature.home.vault.add.AddState
import com.artemchep.keyguard.feature.home.vault.component.Section
import com.artemchep.keyguard.feature.navigation.NavigationIcon
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.DefaultFab
import com.artemchep.keyguard.ui.ExpandedIfNotEmpty
import com.artemchep.keyguard.ui.FabState
import com.artemchep.keyguard.ui.FlatItemLayout
import com.artemchep.keyguard.ui.FlatSimpleNote
import com.artemchep.keyguard.ui.OptionsButton
import com.artemchep.keyguard.ui.ScaffoldColumn
import com.artemchep.keyguard.ui.button.FavouriteToggleButton
import com.artemchep.keyguard.ui.shimmer.shimmer
import com.artemchep.keyguard.ui.skeleton.SkeletonText
import com.artemchep.keyguard.ui.toolbar.LargeToolbar
import dev.icerock.moko.resources.compose.stringResource
@Composable
fun SendAddScreen(
args: SendAddRoute.Args,
) {
val loadableState = produceSendAddScreenState(
args = args,
)
val addScreenScope = remember {
AddScreenScope(
initialFocusRequested = true,
)
}
SendAddScreen(
addScreenScope = addScreenScope,
loadableState = loadableState,
)
}
@Composable
fun SendAddScreen(
addScreenScope: AddScreenScope,
loadableState: Loadable<SendAddState>,
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
ScaffoldColumn(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
topAppBarScrollBehavior = scrollBehavior,
topBar = {
LargeToolbar(
title = {
val title = loadableState.getOrNull()?.title
if (title != null) {
Text(title)
} else {
SkeletonText(
modifier = Modifier
.fillMaxWidth(0.4f),
)
}
},
navigationIcon = {
NavigationIcon()
},
actions = {
val actions = loadableState.getOrNull()?.actions.orEmpty()
OptionsButton(
actions = actions,
)
},
scrollBehavior = scrollBehavior,
)
},
floatingActionState = run {
val fabOnClick = loadableState.getOrNull()?.onSave
val fabState = if (fabOnClick != null) {
FabState(
onClick = fabOnClick,
model = null,
)
} else {
null
}
rememberUpdatedState(newValue = fabState)
},
floatingActionButton = {
DefaultFab(
icon = {
Icon(
imageVector = Icons.Outlined.Save,
contentDescription = null,
)
},
text = {
Text(
text = stringResource(Res.strings.save),
)
},
)
},
columnVerticalArrangement = Arrangement.spacedBy(8.dp),
) {
populateItems(
addScreenScope = addScreenScope,
loadableState = loadableState,
)
}
}
@Composable
private fun ColumnScope.populateItems(
addScreenScope: AddScreenScope,
loadableState: Loadable<SendAddState>,
) = loadableState.fold(
ifLoading = {
populateItemsSkeleton(
addScreenScope = addScreenScope,
)
},
ifOk = { state ->
populateItemsContent(
addScreenScope = addScreenScope,
state = state,
)
},
)
@Composable
private fun ColumnScope.populateItemsSkeleton(
addScreenScope: AddScreenScope,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 8.dp,
vertical = 2.dp,
)
.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
) {
ToolbarContentItemErrSkeleton(
modifier = Modifier
.padding(end = 8.dp),
fraction = 0.5f,
)
}
Section()
Spacer(Modifier.height(24.dp))
with(addScreenScope) {
AddScreenItems()
}
}
@Composable
private fun ColumnScope.populateItemsContent(
addScreenScope: AddScreenScope,
state: SendAddState,
) {
ToolbarContent(
modifier = Modifier,
account = state.ownership.ui.account,
organization = state.ownership.ui.organization,
collection = state.ownership.ui.collection,
folder = state.ownership.ui.folder,
onClick = state.ownership.ui.onClick,
)
Section()
Spacer(Modifier.height(24.dp))
with(addScreenScope) {
AddScreenItems(
items = state.items,
)
}
}

View File

@ -0,0 +1,22 @@
package com.artemchep.keyguard.feature.send.add
import com.artemchep.keyguard.feature.add.AddStateItem
import com.artemchep.keyguard.feature.add.AddStateOwnership
import com.artemchep.keyguard.ui.FlatItemAction
data class SendAddState(
val title: String = "",
val ownership: Ownership,
val actions: List<FlatItemAction> = emptyList(),
val items: List<AddStateItem> = emptyList(),
val onSave: (() -> Unit)? = null,
) {
data class Ownership(
val data: Data,
val ui: AddStateOwnership,
) {
data class Data(
val accountId: String?,
)
}
}

View File

@ -0,0 +1,723 @@
package com.artemchep.keyguard.feature.send.add
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.KeyboardType
import arrow.core.flatten
import arrow.core.partially2
import arrow.optics.Optional
import com.artemchep.keyguard.common.io.attempt
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.effectTap
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.io.toIO
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.common.model.DSend
import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.model.create.CreateRequest
import com.artemchep.keyguard.common.model.create.CreateSendRequest
import com.artemchep.keyguard.common.model.create.address1
import com.artemchep.keyguard.common.model.create.address2
import com.artemchep.keyguard.common.model.create.address3
import com.artemchep.keyguard.common.model.create.city
import com.artemchep.keyguard.common.model.create.company
import com.artemchep.keyguard.common.model.create.country
import com.artemchep.keyguard.common.model.create.email
import com.artemchep.keyguard.common.model.create.firstName
import com.artemchep.keyguard.common.model.create.identity
import com.artemchep.keyguard.common.model.create.lastName
import com.artemchep.keyguard.common.model.create.licenseNumber
import com.artemchep.keyguard.common.model.create.middleName
import com.artemchep.keyguard.common.model.create.note
import com.artemchep.keyguard.common.model.create.passportNumber
import com.artemchep.keyguard.common.model.create.phone
import com.artemchep.keyguard.common.model.create.postalCode
import com.artemchep.keyguard.common.model.create.ssn
import com.artemchep.keyguard.common.model.create.state
import com.artemchep.keyguard.common.model.create.text
import com.artemchep.keyguard.common.model.create.title
import com.artemchep.keyguard.common.model.create.username
import com.artemchep.keyguard.common.model.titleH
import com.artemchep.keyguard.common.service.clipboard.ClipboardService
import com.artemchep.keyguard.common.service.logging.LogRepository
import com.artemchep.keyguard.common.usecase.AddSend
import com.artemchep.keyguard.common.usecase.CipherUnsecureUrlCheck
import com.artemchep.keyguard.common.usecase.GetAccounts
import com.artemchep.keyguard.common.usecase.GetCiphers
import com.artemchep.keyguard.common.usecase.GetCollections
import com.artemchep.keyguard.common.usecase.GetFolders
import com.artemchep.keyguard.common.usecase.GetGravatarUrl
import com.artemchep.keyguard.common.usecase.GetMarkdown
import com.artemchep.keyguard.common.usecase.GetOrganizations
import com.artemchep.keyguard.common.usecase.GetProfiles
import com.artemchep.keyguard.common.usecase.GetSends
import com.artemchep.keyguard.common.usecase.GetTotpCode
import com.artemchep.keyguard.common.usecase.ShowMessage
import com.artemchep.keyguard.common.util.flow.combineToList
import com.artemchep.keyguard.common.util.flow.persistingStateIn
import com.artemchep.keyguard.feature.add.AddStateItem
import com.artemchep.keyguard.feature.add.AddStateOwnership
import com.artemchep.keyguard.feature.add.LocalStateItem
import com.artemchep.keyguard.feature.auth.common.SwitchFieldModel
import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
import com.artemchep.keyguard.feature.auth.common.util.validatedTitle
import com.artemchep.keyguard.feature.confirmation.organization.OrganizationConfirmationResult
import com.artemchep.keyguard.feature.confirmation.organization.OrganizationConfirmationRoute
import com.artemchep.keyguard.feature.home.vault.add.AddRoute
import com.artemchep.keyguard.feature.home.vault.add.TmpIdentity
import com.artemchep.keyguard.feature.home.vault.add.createItem
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
import com.artemchep.keyguard.feature.navigation.state.PersistedStorage
import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope
import com.artemchep.keyguard.feature.navigation.state.copy
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.feature.send.view.SendViewRoute
import com.artemchep.keyguard.platform.parcelize.LeParcelable
import com.artemchep.keyguard.platform.parcelize.LeParcelize
import com.artemchep.keyguard.res.Res
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import org.kodein.di.compose.localDI
import org.kodein.di.direct
import org.kodein.di.instance
@kotlinx.serialization.Serializable
@LeParcelize
data class SendAddOwnershipData(
val accountId: String?,
) : LeParcelable
data class TmpText(
val text: AddStateItem.Text<CreateSendRequest>,
val items: List<AddStateItem>,
)
data class TmpOptions(
val deletionDate: AddStateItem.DateTime<CreateSendRequest>,
val expirationDate: AddStateItem.DateTime<CreateSendRequest>,
val password: AddStateItem.Password<CreateSendRequest>,
val items: List<AddStateItem>,
)
data class TmpNote(
val note: AddStateItem.Note<CreateSendRequest>,
val items: List<AddStateItem>,
)
@Composable
fun produceSendAddScreenState(
args: SendAddRoute.Args,
) = with(localDI().direct) {
produceSendAddScreenState(
args = args,
getAccounts = instance(),
getProfiles = instance(),
getOrganizations = instance(),
getCollections = instance(),
getFolders = instance(),
getCiphers = instance(),
getSends = instance(),
getTotpCode = instance(),
getGravatarUrl = instance(),
getMarkdown = instance(),
logRepository = instance(),
clipboardService = instance(),
cipherUnsecureUrlCheck = instance(),
showMessage = instance(),
addSend = instance(),
)
}
@Composable
fun produceSendAddScreenState(
args: SendAddRoute.Args,
getAccounts: GetAccounts,
getProfiles: GetProfiles,
getOrganizations: GetOrganizations,
getCollections: GetCollections,
getFolders: GetFolders,
getCiphers: GetCiphers,
getSends: GetSends,
getTotpCode: GetTotpCode,
getGravatarUrl: GetGravatarUrl,
getMarkdown: GetMarkdown,
logRepository: LogRepository,
clipboardService: ClipboardService,
cipherUnsecureUrlCheck: CipherUnsecureUrlCheck,
showMessage: ShowMessage,
addSend: AddSend,
): Loadable<SendAddState> = produceScreenState(
key = "send_add",
initial = Loadable.Loading,
args = arrayOf(
args,
getAccounts,
getOrganizations,
getCollections,
getFolders,
getCiphers,
getTotpCode,
),
) {
val copyText = copy(clipboardService)
val markdown = getMarkdown().first()
val title = if (args.ownershipRo) {
translate(Res.strings.addsend_header_edit_title)
} else {
translate(Res.strings.addsend_header_new_title)
}
val ownershipFlow = produceOwnershipFlow(
args = args,
getProfiles = getProfiles,
getSends = getSends,
)
val textHolder = produceTextState(
args = args,
)
val noteHolder = produceNoteState(
args = args,
markdown = markdown,
)
val typeFlow = kotlin.run {
val initialValue = args.type
?: args.initialValue?.type
// this should never happen
?: DSend.Type.None
flowOf(initialValue)
}
val typeItemsFlow = typeFlow
.flatMapLatest { type ->
when (type) {
DSend.Type.Text -> flowOf(textHolder.items)
DSend.Type.File -> flowOf(emptyList())
DSend.Type.None -> flowOf(emptyList())
}
}
val miscItems by lazy {
listOf(
AddStateItem.Section(
id = "misc",
),
AddStateItem.Note(
id = "misc.note",
state = noteHolder.note.state,
markdown = markdown,
),
)
}
val miscFlow = typeFlow
.map { _ ->
val hasNotes = false
hasNotes
}
.distinctUntilChanged()
.map { hasNotes ->
miscItems.takeUnless { hasNotes }.orEmpty()
}
val titleItem = AddStateItem.Title<CreateSendRequest>(
id = "title",
state = LocalStateItem(
flow = kotlin.run {
val key = "title"
val sink = mutablePersistedFlow(key) {
args.initialValue?.name
?: ""
}
val state = asComposeState<String>(key)
combine(
sink
.validatedTitle(this),
typeFlow,
) { validatedTitle, type ->
TextFieldModel2.of(
state = state,
hint = translate(type.titleH()),
validated = validatedTitle,
onChange = state::value::set,
)
}
.persistingStateIn(
scope = screenScope,
started = SharingStarted.WhileSubscribed(1000L),
initialValue = TextFieldModel2.empty,
)
},
populator = { field ->
copy(
title = field.text,
)
},
),
)
val items1 = listOfNotNull(
titleItem,
)
fun stetify(flow: Flow<List<AddStateItem>>) = flow
.map { items ->
items
.mapNotNull { item ->
val stateHolder = item as? AddStateItem.HasState<Any?, CreateSendRequest>
?: return@mapNotNull null
val state = stateHolder.state
val flow = state.flow
.map { v ->
state.populator
.partially2(v)
}
item.id to flow
}
}
val itfff = combine(
typeItemsFlow,
miscFlow,
) { arr ->
arr.toList().flatten()
}
.onEach { l ->
logRepository.post("Foo3", "combine ${l.size}")
}
val outputFlow = combine(
stetify(itfff),
stetify(flowOf(items1)),
) { arr ->
arr
.flatMap {
it
}
}
.flatMapLatest { populatorFlows ->
val typePopulator =
typeFlow
.map { type ->
val f = fun(r: CreateSendRequest): CreateSendRequest {
return r.copy(type = type)
}
f
}
val ownershipPopulator =
ownershipFlow
.map { ownership ->
val f = fun(r: CreateSendRequest): CreateSendRequest {
val requestOwnership = CreateSendRequest.Ownership(
accountId = ownership.data.accountId,
)
return r.copy(ownership = requestOwnership)
}
f
}
(populatorFlows.map { it.second } + ownershipPopulator + typePopulator)
.combineToList()
}
.map { populators ->
populators.fold(
initial = CreateSendRequest(
now = Clock.System.now(),
),
) { y, x -> x(y) }
}
.distinctUntilChanged()
val items = listOf(
AddStateItem.Title<CreateSendRequest>(
id = "title",
state = LocalStateItem(
flow = MutableStateFlow(TextFieldModel2(mutableStateOf(""))),
),
),
AddStateItem.Text<CreateSendRequest>(
id = "text",
state = LocalStateItem(
flow = MutableStateFlow(
value = AddStateItem.Text.State(
value = TextFieldModel2.empty,
label = "Text",
singleLine = false,
),
),
),
),
AddStateItem.Switch<CreateSendRequest>(
id = "text_switch",
title = "Conceal text by default",
state = LocalStateItem(
flow = MutableStateFlow(
value = SwitchFieldModel(
checked = false,
onChange = {},
),
),
),
),
)
combine(
ownershipFlow,
outputFlow,
itfff,
) { ownership, output, ddd ->
val state = SendAddState(
title = title,
ownership = ownership,
items = items1 + ddd,
onSave = {
val request = CreateSendRequest(
ownership = CreateSendRequest.Ownership(
accountId = ownership.data.accountId,
),
title = "title",
note = "note",
// types
type = DSend.Type.Text,
text = CreateSendRequest.Text(
text = "text",
),
// other
now = Clock.System.now(),
)
val sendIdToRequestMap = mapOf(
args.initialValue?.id?.takeIf { args.ownershipRo } to request,
)
addSend(sendIdToRequestMap)
.effectTap {
val intent = kotlin.run {
val list = mutableListOf<NavigationIntent>()
list += NavigationIntent.PopById(screenId, exclusive = false)
if (true) { // TODO: args.behavior.launchEditedCipher
val sendId = it.first()
val accountId = ownership.data.accountId!!
val route = SendViewRoute(
sendId = sendId,
accountId = accountId,
)
list += NavigationIntent.NavigateToRoute(route)
}
NavigationIntent.Composite(
list = list,
)
}
navigate(intent)
}
.launchIn(appScope)
},
)
Loadable.Ok(state)
}
}
private suspend fun RememberStateFlowScope.produceOwnershipFlow(
args: SendAddRoute.Args,
getProfiles: GetProfiles,
getSends: GetSends,
): Flow<SendAddState.Ownership> {
val ro = args.ownershipRo
data class Fool<T>(
val value: T,
val element: AddStateOwnership.Element?,
)
val disk = loadDiskHandle("new_send")
val accountIdSink = mutablePersistedFlow<String?>(
key = "ownership.account_id",
storage = PersistedStorage.InDisk(disk),
) { null }
val initialState = kotlin.run {
if (args.initialValue != null) {
return@run SendAddOwnershipData(
accountId = args.initialValue.accountId,
)
}
// Make an account that has the most ciphers a
// default account.
val accountId = ioEffect {
val accountIds = getProfiles().toIO().bind()
.map { it.accountId() }
.toSet()
fun String.takeIfAccountIdExists() = this
.takeIf { id ->
id in accountIds
}
val lastAccountId = accountIdSink.value?.takeIfAccountIdExists()
if (lastAccountId != null) {
return@ioEffect lastAccountId
}
val ciphers = getSends().toIO().bind()
ciphers
.asSequence()
.groupBy { it.accountId }
// the one that has the most sends
.maxByOrNull { entry -> entry.value.size }
// account id
?.key
?.takeIfAccountIdExists()
}.attempt().bind().getOrNull()
SendAddOwnershipData(
accountId = accountId,
)
}
val sink = mutablePersistedFlow("ownership") {
initialState
}
// If we are creating a new item, then remember the
// last selected account to pre-select it next time.
// TODO: Remember only when an item is created.
sink
.map { it.accountId }
.onEach(accountIdSink::value::set)
.launchIn(screenScope)
val accountFlow = combine(
sink
.map { it.accountId }
.distinctUntilChanged(),
getProfiles(),
) { accountId, profiles ->
if (accountId == null) {
val item = AddStateOwnership.Element.Item(
key = "account.empty",
title = translate(Res.strings.account_none),
stub = true,
)
val el = AddStateOwnership.Element(
readOnly = ro,
items = listOf(item),
)
return@combine Fool(
value = null,
element = el,
)
}
val profileOrNull = profiles
.firstOrNull { it.accountId() == accountId }
val el = AddStateOwnership.Element(
readOnly = ro,
items = listOfNotNull(profileOrNull)
.map { account ->
val key = "account.${account.accountId()}"
AddStateOwnership.Element.Item(
key = key,
title = account.email,
text = account.accountHost,
accentColors = account.accentColor,
)
},
)
Fool(
value = accountId,
element = el,
)
}
return combine(
accountFlow,
) { (account) ->
val flags = if (ro) {
OrganizationConfirmationRoute.Args.RO_ACCOUNT
} else {
0
} or
OrganizationConfirmationRoute.Args.HIDE_ORGANIZATION or
OrganizationConfirmationRoute.Args.HIDE_COLLECTION or
OrganizationConfirmationRoute.Args.HIDE_FOLDER or
OrganizationConfirmationRoute.Args.PREMIUM_ACCOUNT
val data = SendAddState.Ownership.Data(
accountId = account.value,
)
val ui = AddStateOwnership(
account = account.element,
onClick = {
val route = registerRouteResultReceiver(
route = OrganizationConfirmationRoute(
args = OrganizationConfirmationRoute.Args(
decor = OrganizationConfirmationRoute.Args.Decor(
title = translate(Res.strings.save_to),
icon = Icons.Outlined.AccountBox,
),
flags = flags,
accountId = account.value,
),
),
) { result ->
if (result is OrganizationConfirmationResult.Confirm) {
val newState = SendAddOwnershipData(
accountId = result.accountId,
)
sink.value = newState
}
}
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
)
SendAddState.Ownership(
data = data,
ui = ui,
)
}
}
private suspend fun RememberStateFlowScope.produceTextState(
args: SendAddRoute.Args,
): TmpText {
val prefix = "identity"
suspend fun createItem(
key: String,
label: String? = null,
initialValue: String? = null,
singleLine: Boolean = false,
autocompleteOptions: ImmutableList<String> = persistentListOf(),
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
lens: Optional<CreateSendRequest, String>,
) = createItem<CreateSendRequest>(
prefix = prefix,
key = key,
label = label,
initialValue = initialValue,
singleLine = singleLine,
autocompleteOptions = autocompleteOptions,
keyboardOptions = keyboardOptions,
populator = {
lens.set(this, it.value.text)
},
)
val txt = args.initialValue?.text
val text = createItem(
key = "text",
label = translate(Res.strings.identity_title),
initialValue = txt?.text,
singleLine = true,
lens = CreateSendRequest.text.text,
)
return TmpText(
text = text,
items = listOfNotNull<AddStateItem>(
text,
),
)
}
private suspend fun RememberStateFlowScope.produceOptionsState(
args: SendAddRoute.Args,
): TmpText {
val prefix = "options"
suspend fun createItem(
key: String,
label: String? = null,
initialValue: String? = null,
singleLine: Boolean = false,
autocompleteOptions: ImmutableList<String> = persistentListOf(),
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
lens: Optional<CreateSendRequest, String>,
) = createItem<CreateSendRequest>(
prefix = prefix,
key = key,
label = label,
initialValue = initialValue,
singleLine = singleLine,
autocompleteOptions = autocompleteOptions,
keyboardOptions = keyboardOptions,
populator = {
lens.set(this, it.value.text)
},
)
val txt = args.initialValue?.text
val text = createItem(
key = "text",
label = translate(Res.strings.identity_title),
initialValue = txt?.text,
singleLine = true,
lens = CreateSendRequest.text.text,
)
return TmpText(
text = text,
items = listOfNotNull<AddStateItem>(
text,
),
)
}
private suspend fun RememberStateFlowScope.produceNoteState(
args: SendAddRoute.Args,
markdown: Boolean,
): TmpNote {
val prefix = "notes"
val note = kotlin.run {
val id = "$prefix.note"
val initialValue = args.initialValue?.notes
val sink = mutablePersistedFlow(id) { initialValue.orEmpty() }
val state = mutableComposeState(sink)
val stateItem = LocalStateItem<TextFieldModel2, CreateSendRequest>(
flow = sink
.map { value ->
val model = TextFieldModel2(
state = state,
text = value,
hint = "Add any notes about this item here",
onChange = state::value::set,
)
model
}
.persistingStateIn(
scope = screenScope,
started = SharingStarted.WhileSubscribed(),
initialValue = TextFieldModel2.empty,
),
populator = { state ->
CreateSendRequest.note.set(this, state.text)
},
)
AddStateItem.Note(
id = id,
state = stateItem,
markdown = markdown,
)
}
return TmpNote(
note = note,
items = listOfNotNull<AddStateItem>(
note,
),
)
}

View File

@ -1,13 +1,15 @@
package com.artemchep.keyguard.feature.send.list
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -15,11 +17,14 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccessTime
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.pullrefresh.PullRefreshState
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalAbsoluteTonalElevation
@ -50,7 +55,6 @@ import com.artemchep.keyguard.common.model.DSend
import com.artemchep.keyguard.common.model.expiredFlow
import com.artemchep.keyguard.feature.EmptySearchView
import com.artemchep.keyguard.feature.home.vault.component.FlatItemLayout2
import com.artemchep.keyguard.feature.home.vault.component.SearchTextField
import com.artemchep.keyguard.feature.home.vault.component.Section
import com.artemchep.keyguard.feature.home.vault.component.rememberSecretAccentColor
import com.artemchep.keyguard.feature.home.vault.component.surfaceColorAtElevationSemi
@ -74,8 +78,15 @@ import com.artemchep.keyguard.ui.AvatarBadgeIcon
import com.artemchep.keyguard.ui.AvatarBuilder
import com.artemchep.keyguard.ui.CollectedEffect
import com.artemchep.keyguard.ui.Compose
import com.artemchep.keyguard.ui.DefaultFab
import com.artemchep.keyguard.ui.DefaultSelection
import com.artemchep.keyguard.ui.DropdownMenuItemFlat
import com.artemchep.keyguard.ui.DropdownMinWidth
import com.artemchep.keyguard.ui.DropdownScopeImpl
import com.artemchep.keyguard.ui.ExpandedIfNotEmptyForRow
import com.artemchep.keyguard.ui.FabState
import com.artemchep.keyguard.ui.FlatItem
import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.FlatItemTextContent
import com.artemchep.keyguard.ui.MediumEmphasisAlpha
import com.artemchep.keyguard.ui.OptionsButton
@ -83,6 +94,7 @@ import com.artemchep.keyguard.ui.ScaffoldLazyColumn
import com.artemchep.keyguard.ui.focus.FocusRequester2
import com.artemchep.keyguard.ui.focus.focusRequester2
import com.artemchep.keyguard.ui.icons.ChevronIcon
import com.artemchep.keyguard.ui.icons.IconBox
import com.artemchep.keyguard.ui.icons.KeyguardNote
import com.artemchep.keyguard.ui.icons.KeyguardView
import com.artemchep.keyguard.ui.pulltosearch.PullToSearch
@ -224,6 +236,12 @@ private fun SendScreenContent(
pullRefreshState: PullRefreshState,
scrollBehavior: TopAppBarScrollBehavior,
) {
val dp = remember {
mutableStateOf(false)
}
if (state.primaryActions.isEmpty()) {
dp.value = false
}
ScaffoldLazyColumn(
modifier = modifier
.pullRefresh(pullRefreshState)
@ -263,7 +281,77 @@ private fun SendScreenContent(
)
}
},
floatingActionState = run {
val fabVisible = state.primaryActions.isNotEmpty()
val fabState = if (fabVisible) {
// If there's only one primary action, then there's no
// need to show the dropdown.
val onClick = run {
val action =
state.primaryActions.firstNotNullOfOrNull { it as? FlatItemAction }
action?.onClick
?.takeIf {
val count = state.primaryActions
.count { it is FlatItemAction }
count == 1
}
} ?:
// lambda
{
dp.value = true
}
FabState(
onClick = onClick,
model = null,
)
} else {
null
}
rememberUpdatedState(newValue = fabState)
},
floatingActionButton = {
DefaultFab(
icon = {
IconBox(main = Icons.Outlined.Add)
// Inject the dropdown popup to the bottom of the
// content.
val onDismissRequest = remember(dp) {
// lambda
{
dp.value = false
}
}
DropdownMenu(
modifier = Modifier
.widthIn(min = DropdownMinWidth),
expanded = dp.value,
onDismissRequest = onDismissRequest,
) {
val scope = DropdownScopeImpl(this, onDismissRequest = onDismissRequest)
with(scope) {
state.primaryActions.forEachIndexed { index, action ->
DropdownMenuItemFlat(
action = action,
)
}
}
}
},
text = {
Text(
text = stringResource(Res.strings.send_main_new_item_button),
)
},
)
},
pullRefreshState = pullRefreshState,
bottomBar = {
val selectionOrNull = (state.content as? SendListState.Content.Items)?.selection
DefaultSelection(
state = selectionOrNull,
)
},
overlay = {
PullToSearch(
modifier = Modifier
@ -602,7 +690,33 @@ fun VaultSendItemText(
Spacer(modifier = Modifier.width(8.dp))
ChevronIcon()
val showCheckbox = when {
localState.selectableItemState.selecting -> true
else -> false
}
Crossfade(
modifier = Modifier
.size(
width = 36.dp,
height = 36.dp,
),
targetState = showCheckbox,
) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (showCheckbox) {
Checkbox(
checked = localState.selectableItemState.selected,
onCheckedChange = null,
)
} else {
ChevronIcon()
}
}
}
},
onClick = onClick,
onLongClick = onLongClick,

View File

@ -188,7 +188,7 @@ suspend fun <
}
.distinctUntilChanged()
.map { items ->
if (items.size <= 1 && collapse) {
if (items.size <= 1 && collapse || items.isEmpty()) {
// Do not show a single filter item.
return@map emptyList<SendFilterItem>()
}

View File

@ -0,0 +1,758 @@
package com.artemchep.keyguard.feature.send.util
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material.icons.outlined.DeleteForever
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Email
import androidx.compose.material.icons.outlined.Password
import androidx.compose.material.icons.outlined.Remove
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import arrow.core.some
import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.model.DProfile
import com.artemchep.keyguard.common.model.DSend
import com.artemchep.keyguard.common.model.PatchSendRequest
import com.artemchep.keyguard.common.usecase.PatchSendById
import com.artemchep.keyguard.common.usecase.RemoveSendById
import com.artemchep.keyguard.common.usecase.SendToolbox
import com.artemchep.keyguard.common.util.StringComparatorIgnoreCase
import com.artemchep.keyguard.feature.confirmation.ConfirmationResult
import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope
import com.artemchep.keyguard.platform.util.isRelease
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.Selection
import com.artemchep.keyguard.ui.buildContextItems
import com.artemchep.keyguard.ui.icons.Stub
import com.artemchep.keyguard.ui.icons.icon
import com.artemchep.keyguard.ui.icons.iconSmall
import com.artemchep.keyguard.ui.selection.SelectionHandle
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
object SendUtil {
context(RememberStateFlowScope)
fun renameActionOrNull(
patchSendById: PatchSendById,
sends: List<DSend>,
canEdit: Boolean,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
if (!canEdit) {
return@run null
}
val icon = icon(Icons.Outlined.Edit)
val title = if (sends.size > 1) {
translate(Res.strings.sends_action_change_names_title)
} else {
translate(Res.strings.sends_action_change_name_title)
}
FlatItemAction(
leading = icon,
title = title,
onClick = {
before?.invoke()
val items = sends
.sorted()
.map { cipher ->
ConfirmationRoute.Args.Item.StringItem(
key = cipher.id,
value = cipher.name,
title = cipher.name,
type = ConfirmationRoute.Args.Item.StringItem.Type.Text,
canBeEmpty = false,
)
}
val route = registerRouteResultReceiver(
route = ConfirmationRoute(
args = ConfirmationRoute.Args(
icon = icon,
title = title,
items = items,
),
),
) { result ->
if (result is ConfirmationResult.Confirm) {
val request = createPatchRequestMultiple(result.data) { name ->
val nameFixed = name as String
PatchSendRequest.Data(
name = nameFixed.some(),
)
}
patchSendById(request)
.launchIn(appScope)
}
val success = result is ConfirmationResult.Confirm
after?.invoke(success)
}
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
)
}
context(RememberStateFlowScope)
fun renameFileActionOrNull(
patchSendById: PatchSendById,
sends: List<DSend>,
canEdit: Boolean,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
if (!canEdit) {
return@run null
}
val allFiles = sends.all { it.file != null }
if (!allFiles) {
return@run null
}
// TODO: Seems like at this moment we can not change the file name
if (isRelease) return@run null
val icon = iconSmall(Icons.Outlined.Attachment, Icons.Outlined.Edit)
val title = if (sends.size > 1) {
translate(Res.strings.sends_action_change_filenames_title)
} else {
translate(Res.strings.sends_action_change_filename_title)
}
FlatItemAction(
leading = icon,
title = title,
onClick = {
before?.invoke()
val items = sends
.sorted()
.map { cipher ->
ConfirmationRoute.Args.Item.StringItem(
key = cipher.id,
value = cipher.file?.fileName.orEmpty(),
title = cipher.name,
type = ConfirmationRoute.Args.Item.StringItem.Type.Text,
canBeEmpty = false,
)
}
val route = registerRouteResultReceiver(
route = ConfirmationRoute(
args = ConfirmationRoute.Args(
icon = icon,
title = title,
items = items,
),
),
) { result ->
if (result is ConfirmationResult.Confirm) {
val request = createPatchRequestMultiple(result.data) { name ->
val nameFixed = name as String
PatchSendRequest.Data(
fileName = nameFixed.some(),
)
}
patchSendById(request)
.launchIn(appScope)
}
val success = result is ConfirmationResult.Confirm
after?.invoke(success)
}
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
)
}
context(RememberStateFlowScope)
fun changePasswordActionOrNull(
patchSendById: PatchSendById,
sends: List<DSend>,
canEdit: Boolean,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
if (!canEdit) {
return@run null
}
val hasPasswords = sends.any { it.hasPassword }
val icon = iconSmall(Icons.Outlined.Password)
val title = if (hasPasswords) {
if (sends.size > 1) {
translate(Res.strings.sends_action_change_passwords_title)
} else {
translate(Res.strings.sends_action_change_password_title)
}
} else {
if (sends.size > 1) {
translate(Res.strings.sends_action_set_passwords_title)
} else {
translate(Res.strings.sends_action_set_password_title)
}
}
FlatItemAction(
leading = icon,
title = title,
onClick = {
before?.invoke()
val items = sends
.sorted()
.map { send ->
ConfirmationRoute.Args.Item.StringItem(
key = send.id,
value = "",
title = send.name,
type = ConfirmationRoute.Args.Item.StringItem.Type.Password,
canBeEmpty = true, // so you can clear passwords
)
}
val route = registerRouteResultReceiver(
route = ConfirmationRoute(
args = ConfirmationRoute.Args(
icon = icon,
title = title,
message = if (hasPasswords) {
null
} else {
translate(Res.strings.sends_action_set_password_confirmation_message)
},
items = items,
),
),
) { result ->
if (result is ConfirmationResult.Confirm) {
val request = createPatchRequestMultiple(result.data) { password ->
val passwordFixed = (password as String)
.takeIf { it.isNotEmpty() }
PatchSendRequest.Data(
password = passwordFixed.some(),
)
}
patchSendById(request)
.launchIn(appScope)
}
val success = result is ConfirmationResult.Confirm
after?.invoke(success)
}
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
)
}
context(RememberStateFlowScope)
fun removePasswordActionOrNull(
patchSendById: PatchSendById,
sends: List<DSend>,
canEdit: Boolean,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
if (!canEdit) {
return@run null
}
val hasPasswords = sends.any { it.hasPassword }
if (!hasPasswords) {
return@run null
}
val icon = iconSmall(Icons.Outlined.Password, Icons.Outlined.Remove)
val title = if (sends.size > 1) {
translate(Res.strings.sends_action_remove_passwords_title)
} else {
translate(Res.strings.sends_action_remove_password_title)
}
FlatItemAction(
leading = icon,
title = title,
onClick = {
before?.invoke()
val route = registerRouteResultReceiver(
route = ConfirmationRoute(
args = ConfirmationRoute.Args(
icon = icon(Icons.Outlined.Password, Icons.Outlined.Remove),
title = if (sends.size > 1) {
translate(Res.strings.sends_action_remove_passwords_confirmation_title)
} else {
translate(Res.strings.sends_action_remove_password_confirmation_title)
},
message = translate(Res.strings.sends_action_remove_password_confirmation_message),
),
),
) { result ->
if (result is ConfirmationResult.Confirm) {
val request = createPatchRequestSingle(sends) {
PatchSendRequest.Data(
password = null.some(),
)
}
patchSendById(request)
.launchIn(appScope)
}
val success = result is ConfirmationResult.Confirm
after?.invoke(success)
}
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
)
}
context(RememberStateFlowScope)
fun showEmailActionOrNull(
patchSendById: PatchSendById,
sends: List<DSend>,
canEdit: Boolean,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
if (!canEdit) {
return@run null
}
val canShowEmail = sends.any { it.hideEmail }
if (!canShowEmail) {
return@run null
}
showEmailAction(
patchSendById = patchSendById,
sends = sends,
before = before,
after = after,
)
}
context(RememberStateFlowScope)
fun showEmailAction(
patchSendById: PatchSendById,
sends: List<DSend>,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
val icon = iconSmall(Icons.Outlined.Email, Icons.Outlined.Visibility)
val title = translate(Res.strings.sends_action_show_email_title)
FlatItemAction(
leading = icon,
title = title,
onClick = {
before?.invoke()
val request = createPatchRequestSingle(sends) {
PatchSendRequest.Data(
hideEmail = false.some(),
)
}
patchSendById(request)
.launchIn(appScope)
},
)
}
context(RememberStateFlowScope)
fun hideEmailActionOrNull(
patchSendById: PatchSendById,
sends: List<DSend>,
canEdit: Boolean,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
if (!canEdit) {
return@run null
}
val canHideEmail = sends.any { !it.hideEmail }
if (!canHideEmail) {
return@run null
}
hideEmailAction(
patchSendById = patchSendById,
sends = sends,
before = before,
after = after,
)
}
context(RememberStateFlowScope)
fun hideEmailAction(
patchSendById: PatchSendById,
sends: List<DSend>,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
val icon = iconSmall(Icons.Outlined.Email, Icons.Outlined.VisibilityOff)
val title = translate(Res.strings.sends_action_hide_email_title)
FlatItemAction(
leading = icon,
title = title,
onClick = {
before?.invoke()
val request = createPatchRequestSingle(sends) {
PatchSendRequest.Data(
hideEmail = true.some(),
)
}
patchSendById(request)
.launchIn(appScope)
},
)
}
context(RememberStateFlowScope)
fun enableActionOrNull(
patchSendById: PatchSendById,
sends: List<DSend>,
canEdit: Boolean,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
if (!canEdit) {
return@run null
}
val canEnable = sends.any { it.disabled }
if (!canEnable) {
return@run null
}
enableAction(
patchSendById = patchSendById,
sends = sends,
before = before,
after = after,
)
}
context(RememberStateFlowScope)
fun enableAction(
patchSendById: PatchSendById,
sends: List<DSend>,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
val icon = icon(Icons.Stub)
val title = translate(Res.strings.sends_action_enable_title)
val text = translate(Res.strings.sends_action_enable_text)
FlatItemAction(
leading = icon,
title = title,
text = text,
onClick = {
before?.invoke()
val route = registerRouteResultReceiver(
route = ConfirmationRoute(
args = ConfirmationRoute.Args(
title = translate(Res.strings.sends_action_enable_confirmation_title),
),
),
) { result ->
if (result is ConfirmationResult.Confirm) {
val request = createPatchRequestSingle(sends) {
PatchSendRequest.Data(
disabled = false.some(),
)
}
patchSendById(request)
.launchIn(appScope)
}
val success = result is ConfirmationResult.Confirm
after?.invoke(success)
}
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
)
}
context(RememberStateFlowScope)
fun disableActionOrNull(
patchSendById: PatchSendById,
sends: List<DSend>,
canEdit: Boolean,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
if (!canEdit) {
return@run null
}
val canDisable = sends.any { !it.disabled }
if (!canDisable) {
return@run null
}
disableAction(
patchSendById = patchSendById,
sends = sends,
before = before,
after = after,
)
}
context(RememberStateFlowScope)
fun disableAction(
patchSendById: PatchSendById,
sends: List<DSend>,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
val icon = icon(Icons.Stub)
val title = translate(Res.strings.sends_action_disable_title)
val text = translate(Res.strings.sends_action_disable_text)
FlatItemAction(
leading = icon,
title = title,
text = text,
onClick = {
before?.invoke()
val route = registerRouteResultReceiver(
route = ConfirmationRoute(
args = ConfirmationRoute.Args(
title = translate(Res.strings.sends_action_disable_confirmation_title),
),
),
) { result ->
if (result is ConfirmationResult.Confirm) {
val request = createPatchRequestSingle(sends) {
PatchSendRequest.Data(
disabled = true.some(),
)
}
patchSendById(request)
.launchIn(appScope)
}
val success = result is ConfirmationResult.Confirm
after?.invoke(success)
}
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
)
}
context(RememberStateFlowScope)
fun deleteActionOrNull(
removeSendById: RemoveSendById,
sends: List<DSend>,
canDelete: Boolean,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
if (!canDelete) {
return@run null
}
deleteAction(
removeSendById = removeSendById,
sends = sends,
before = before,
after = after,
)
}
context(RememberStateFlowScope)
fun deleteAction(
removeSendById: RemoveSendById,
sends: List<DSend>,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
val icon = icon(Icons.Outlined.DeleteForever)
val title = translate(Res.strings.sends_action_delete_title)
FlatItemAction(
leading = icon,
title = title,
onClick = {
before?.invoke()
val route = registerRouteResultReceiver(
route = ConfirmationRoute(
args = ConfirmationRoute.Args(
icon = icon(Icons.Outlined.DeleteForever),
title = translate(Res.strings.sends_action_delete_confirmation_title),
),
),
) { result ->
if (result is ConfirmationResult.Confirm) {
val sendIds = sends
.map { it.id }
.toSet()
removeSendById(sendIds)
.launchIn(appScope)
}
val success = result is ConfirmationResult.Confirm
after?.invoke(success)
}
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
)
}
private fun List<DSend>.sorted() = this
.sortedWith(StringComparatorIgnoreCase { it.name })
private fun createPatchRequestSingle(
sends: List<DSend>,
factory: () -> PatchSendRequest.Data,
): PatchSendRequest = kotlin.run {
val data = factory()
val patch = sends
.associate {
it.id to data
}
PatchSendRequest(
patch = patch,
)
}
private fun <T> createPatchRequestMultiple(
data: Map<String, T>,
factory: (T) -> PatchSendRequest.Data,
): PatchSendRequest = kotlin.run {
val patch = data
.mapValues {
factory(it.value)
}
PatchSendRequest(
patch = patch,
)
}
context(RememberStateFlowScope)
fun actions(
toolbox: SendToolbox,
sends: List<DSend>,
canEdit: Boolean,
) = kotlin.run {
buildContextItems {
section {
this += renameActionOrNull(
patchSendById = toolbox.patchSendById,
sends = sends,
canEdit = canEdit,
)
this += renameFileActionOrNull(
patchSendById = toolbox.patchSendById,
sends = sends,
canEdit = canEdit,
)
}
section {
this += changePasswordActionOrNull(
patchSendById = toolbox.patchSendById,
sends = sends,
canEdit = canEdit,
)
this += removePasswordActionOrNull(
patchSendById = toolbox.patchSendById,
sends = sends,
canEdit = canEdit,
)
}
section {
this += showEmailActionOrNull(
patchSendById = toolbox.patchSendById,
sends = sends,
canEdit = canEdit,
)
this += hideEmailActionOrNull(
patchSendById = toolbox.patchSendById,
sends = sends,
canEdit = canEdit,
)
}
section {
this += enableActionOrNull(
patchSendById = toolbox.patchSendById,
sends = sends,
canEdit = canEdit,
)
this += disableActionOrNull(
patchSendById = toolbox.patchSendById,
sends = sends,
canEdit = canEdit,
)
this += deleteActionOrNull(
removeSendById = toolbox.removeSendById,
sends = sends,
canDelete = canEdit,
)
}
}
}
//
// Selection
//
fun canEditFlow(
profilesFlow: Flow<List<DProfile>>,
canWriteFlow: Flow<Boolean>,
) = kotlin.run {
canWriteFlow
.flatMapLatest { canWrite ->
if (!canWrite) {
return@flatMapLatest flowOf(false)
}
// Using send requires a user to have the
// Bitwarden premium. We don't want to show the
// new item button if a user won't be able to
// create an item anyway.
profilesFlow
.map { profiles ->
profiles.any { it.premium }
}
}
.distinctUntilChanged()
}
context(RememberStateFlowScope)
fun selectionFlow(
selectionHandle: SelectionHandle,
sendsFlow: Flow<List<DSend>>,
canEditFlow: Flow<Boolean>,
//
toolbox: SendToolbox,
) = combine(
sendsFlow,
canEditFlow,
selectionHandle.idsFlow,
) { sends, canEdit, selectedSendIds ->
if (selectedSendIds.isEmpty()) {
return@combine null
}
val selectedSends = sends
.filter { it.id in selectedSendIds }
val actions = actions(
toolbox = toolbox,
sends = selectedSends,
canEdit = canEdit,
)
Selection(
count = selectedSends.size,
actions = actions.toPersistentList(),
onClear = selectionHandle::clearSelection,
)
}
}

View File

@ -5,6 +5,7 @@ import arrow.optics.optics
import com.artemchep.keyguard.common.model.DSend
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
import com.artemchep.keyguard.feature.home.vault.model.VaultViewItem
import com.artemchep.keyguard.ui.ContextItem
import com.artemchep.keyguard.ui.FlatItemAction
@Immutable
@ -27,7 +28,7 @@ data class SendViewState(
val synced: Boolean,
val onCopy: (() -> Unit)?,
val onShare: (() -> Unit)?,
val actions: List<FlatItemAction>,
val actions: List<ContextItem>,
val items: List<VaultViewItem>,
) : Content {
companion object;

View File

@ -57,10 +57,13 @@ import com.artemchep.keyguard.common.usecase.GetPasswordStrength
import com.artemchep.keyguard.common.usecase.GetSends
import com.artemchep.keyguard.common.usecase.GetWebsiteIcons
import com.artemchep.keyguard.common.usecase.MoveCipherToFolderById
import com.artemchep.keyguard.common.usecase.PatchSendById
import com.artemchep.keyguard.common.usecase.RemoveAttachment
import com.artemchep.keyguard.common.usecase.RemoveCipherById
import com.artemchep.keyguard.common.usecase.RemoveSendById
import com.artemchep.keyguard.common.usecase.RestoreCipherById
import com.artemchep.keyguard.common.usecase.RetryCipher
import com.artemchep.keyguard.common.usecase.SendToolbox
import com.artemchep.keyguard.common.usecase.TrashCipherById
import com.artemchep.keyguard.common.usecase.WindowCoroutineScope
import com.artemchep.keyguard.feature.attachments.model.AttachmentItem
@ -78,8 +81,10 @@ import com.artemchep.keyguard.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.feature.send.action.createSendActionOrNull
import com.artemchep.keyguard.feature.send.action.createShareAction
import com.artemchep.keyguard.feature.send.toVaultItemIcon
import com.artemchep.keyguard.feature.send.util.SendUtil
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.autoclose.launchAutoPopSelfHandler
import com.artemchep.keyguard.ui.buildContextItems
import com.artemchep.keyguard.ui.icons.ChevronIcon
import com.artemchep.keyguard.ui.icons.IconBox
@ -89,6 +94,7 @@ import com.artemchep.keyguard.ui.selection.selectionHandle
import com.artemchep.keyguard.ui.text.annotate
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import io.ktor.http.Url
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
@ -120,18 +126,8 @@ fun sendViewScreenState(
getAppIcons = instance(),
getWebsiteIcons = instance(),
getPasswordStrength = instance(),
cipherUnsecureUrlCheck = instance(),
cipherUnsecureUrlAutoFix = instance(),
cipherFieldSwitchToggle = instance(),
moveCipherToFolderById = instance(),
changeCipherNameById = instance(),
changeCipherPasswordById = instance(),
retryCipher = instance(),
copyCipherById = instance(),
restoreCipherById = instance(),
trashCipherById = instance(),
removeCipherById = instance(),
favouriteCipherById = instance(),
toolbox = instance(),
downloadManager = instance(),
downloadAttachment = instance(),
removeAttachment = instance(),
@ -173,18 +169,8 @@ fun sendViewScreenState(
getAppIcons: GetAppIcons,
getWebsiteIcons: GetWebsiteIcons,
getPasswordStrength: GetPasswordStrength,
cipherUnsecureUrlCheck: CipherUnsecureUrlCheck,
cipherUnsecureUrlAutoFix: CipherUnsecureUrlAutoFix,
cipherFieldSwitchToggle: CipherFieldSwitchToggle,
moveCipherToFolderById: MoveCipherToFolderById,
changeCipherNameById: ChangeCipherNameById,
changeCipherPasswordById: ChangeCipherPasswordById,
retryCipher: RetryCipher,
copyCipherById: CopyCipherById,
restoreCipherById: RestoreCipherById,
trashCipherById: TrashCipherById,
removeCipherById: RemoveCipherById,
favouriteCipherById: FavouriteCipherById,
toolbox: SendToolbox,
downloadManager: DownloadManager,
downloadAttachment: DownloadAttachment,
removeAttachment: RemoveAttachment,
@ -241,6 +227,7 @@ fun sendViewScreenState(
.firstOrNull { it.id == sendId && it.accountId == accountId }
}
.distinctUntilChanged()
launchAutoPopSelfHandler(secretFlow)
combine(
accountFlow,
secretFlow,
@ -276,11 +263,17 @@ fun sendViewScreenState(
} else {
null
}
val actions = SendUtil.actions(
toolbox = toolbox,
sends = listOf(secretOrNull),
canEdit = canAddSecret,
)
SendViewState.Content.Cipher(
data = secretOrNull,
icon = icon,
synced = true,
actions = emptyList(),
actions = actions,
onCopy = if (info != null) {
// lambda
{
@ -393,8 +386,7 @@ private fun RememberStateFlowScope.oh(
emit(section)
emit(url)
val password = send.password
if (password != null) {
if (send.hasPassword) {
val w = VaultViewItem.Label(
id = "url.password",
text = AnnotatedString(

View File

@ -12,6 +12,7 @@ import com.artemchep.keyguard.core.store.DatabaseSyncer
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCollection
import com.artemchep.keyguard.core.store.bitwarden.BitwardenFolder
import com.artemchep.keyguard.core.store.bitwarden.BitwardenOptionalStringNullable
import com.artemchep.keyguard.core.store.bitwarden.BitwardenOrganization
import com.artemchep.keyguard.core.store.bitwarden.BitwardenProfile
import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend
@ -25,6 +26,7 @@ import com.artemchep.keyguard.provider.bitwarden.api.builder.delete
import com.artemchep.keyguard.provider.bitwarden.api.builder.get
import com.artemchep.keyguard.provider.bitwarden.api.builder.post
import com.artemchep.keyguard.provider.bitwarden.api.builder.put
import com.artemchep.keyguard.provider.bitwarden.api.builder.removePassword
import com.artemchep.keyguard.provider.bitwarden.api.builder.restore
import com.artemchep.keyguard.provider.bitwarden.api.builder.sync
import com.artemchep.keyguard.provider.bitwarden.api.builder.trash
@ -33,11 +35,13 @@ import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCr
import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrCta
import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrImpl
import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrKey
import com.artemchep.keyguard.provider.bitwarden.crypto.CryptoKey
import com.artemchep.keyguard.provider.bitwarden.crypto.appendOrganizationToken
import com.artemchep.keyguard.provider.bitwarden.crypto.appendProfileToken
import com.artemchep.keyguard.provider.bitwarden.crypto.appendSendToken
import com.artemchep.keyguard.provider.bitwarden.crypto.appendUserToken
import com.artemchep.keyguard.provider.bitwarden.crypto.decodeSymmetricOrThrow
import com.artemchep.keyguard.provider.bitwarden.crypto.encrypted
import com.artemchep.keyguard.provider.bitwarden.crypto.makeSendCryptoKey
import com.artemchep.keyguard.provider.bitwarden.crypto.transform
import com.artemchep.keyguard.provider.bitwarden.entity.CipherEntity
import com.artemchep.keyguard.provider.bitwarden.entity.CollectionEntity
@ -47,6 +51,7 @@ import com.artemchep.keyguard.provider.bitwarden.entity.SyncProfile
import com.artemchep.keyguard.provider.bitwarden.entity.SyncSends
import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherUpdate
import com.artemchep.keyguard.provider.bitwarden.entity.request.FolderUpdate
import com.artemchep.keyguard.provider.bitwarden.entity.request.SendUpdate
import com.artemchep.keyguard.provider.bitwarden.entity.request.of
import com.artemchep.keyguard.provider.bitwarden.sync.SyncManager
import io.ktor.client.HttpClient
@ -122,7 +127,6 @@ class SyncEngine(
val now = Clock.System.now()
val crypto = crypto(
profile = response.profile,
sends = response.sends.orEmpty(),
)
fun getCodec(
@ -135,7 +139,7 @@ class SyncEngine(
val key = BitwardenCrKey.SendToken(sendId)
BitwardenCrCta.BitwardenCrCtaEnv(
key = key,
encryptionType = CipherEncryptor.Type.AesCbc256_B64,
encryptionType = envEncryptionType,
)
} else if (organizationId != null) {
val key = BitwardenCrKey.OrganizationToken(organizationId)
@ -156,6 +160,43 @@ class SyncEngine(
)
}
fun getCodecPair(
mode: BitwardenCrCta.Mode,
key: ByteArray,
) = kotlin.run {
val itemCrypto = kotlin.run {
val symmetricCryptoKey = key
.let(cryptoGenerator::makeSendCryptoKey)
.let(CryptoKey.Companion::decodeSymmetricOrThrow)
val cryptoKey = BitwardenCrKey.CryptoKey(
symmetricCryptoKey = symmetricCryptoKey,
)
val cryptoKeyEnv = BitwardenCrCta.BitwardenCrCtaEnv(
key = cryptoKey,
)
crypto.cta(
env = cryptoKeyEnv,
mode = mode,
)
}
val globalCrypto = getCodec(
mode = mode,
)
itemCrypto to globalCrypto
}
fun getCodecPairFromEncrypted(
mode: BitwardenCrCta.Mode,
keyCipherText: String,
) = kotlin.run {
val key = crypto.decoder(BitwardenCrKey.UserToken)(keyCipherText)
.data
getCodecPair(
key = key,
mode = mode,
)
}
//
// Profile
//
@ -940,8 +981,28 @@ class SyncEngine(
localReEncoder = { model ->
model
},
localDecoder = { l, d ->
Unit
localDecoder = { local, remote ->
val itemKey = requireNotNull(local.keyBase64)
.let(base64Service::decode)
val (
itemCrypto,
globalCrypto,
) = getCodecPair(
mode = BitwardenCrCta.Mode.ENCRYPT,
key = itemKey,
)
val encryptedSend = local.transform(
itemCrypto = itemCrypto,
globalCrypto = globalCrypto,
)
with(cryptoGenerator) {
with(base64Service) {
SendUpdate.of(
model = encryptedSend,
key = itemKey,
)
}
} to local
},
localDeleteById = { ids ->
sendDao.transaction {
@ -966,32 +1027,36 @@ class SyncEngine(
remoteItems = response.sends.orEmpty(),
remoteLens = SyncManager.Lens<SyncSends>(
getId = { it.id },
getRevisionDate = { Instant.DISTANT_FUTURE },
getRevisionDate = { it.revisionDate },
),
remoteDecoder = { remote, local ->
val codec = getCodec(
val (
itemCrypto,
globalCrypto,
) = getCodecPairFromEncrypted(
mode = BitwardenCrCta.Mode.DECRYPT,
sendId = remote.id,
keyCipherText = remote.key,
)
val codec2 = getCodec(
mode = BitwardenCrCta.Mode.DECRYPT,
)
codec
itemCrypto
.sendDecoder(
entity = remote,
codec2 = codec2,
codec2 = globalCrypto,
localSyncId = local?.sendId,
)
},
remoteDeleteById = { id ->
TODO()
user.env.back().api.sends.focus(id).delete(
httpClient = httpClient,
env = env,
token = user.token.accessToken,
)
},
remoteDecodedFallback = { remote, localOrNull, e ->
e.printStackTrace()
val service = BitwardenService(
remote = BitwardenService.Remote(
id = remote.id,
revisionDate = now,
revisionDate = remote.revisionDate,
deletedDate = null,
),
error = BitwardenService.Error(
@ -1015,8 +1080,68 @@ class SyncEngine(
)
model
},
remotePut = {
TODO()
remotePut = { (r, local) ->
val sendsApi = user.env.back().api.sends
val sendResponse = when (r) {
is SendUpdate.Modify -> {
val cipherApi = sendsApi.focus(r.sendId)
var cipherRequest = r.sendRequest
val hasChanged =
r.source.service.remote?.revisionDate != r.source.revisionDate
if (hasChanged) {
val putCipher = cipherApi.put(
httpClient = httpClient,
env = env,
token = user.token.accessToken,
body = cipherRequest,
)
// Removing a password has a separate endpoint and
// requires an additional request.
val shouldRemovePassword = r.source.changes?.passwordBase64
?.let { it is BitwardenOptionalStringNullable.Some && it.value == null } == true
if (shouldRemovePassword && putCipher.password != null) {
cipherApi.removePassword(
httpClient = httpClient,
env = env,
token = user.token.accessToken,
)
}
}
cipherApi.get(
httpClient = httpClient,
env = env,
token = user.token.accessToken,
)
}
is SendUpdate.Create -> {
sendsApi.post(
httpClient = httpClient,
env = env,
token = user.token.accessToken,
body = r.sendRequest,
)
}
}
val itemKey = requireNotNull(local.keyBase64)
.let(base64Service::decode)
val (
itemCrypto,
globalCrypto,
) = getCodecPair(
mode = BitwardenCrCta.Mode.DECRYPT,
key = itemKey,
)
itemCrypto
.sendDecoder(
entity = sendResponse,
codec2 = globalCrypto,
localSyncId = local.sendId,
)
},
onLog = { msg, logLevel ->
logRepository.post(TAG, msg, logLevel)
@ -1030,7 +1155,6 @@ class SyncEngine(
private fun crypto(
profile: SyncProfile,
sends: List<SyncSends>,
): BitwardenCr = kotlin.run {
val builder = BitwardenCrImpl(
cipherEncryptor = cipherEncryptor,
@ -1054,13 +1178,6 @@ class SyncEngine(
keyCipherText = organization.key,
)
}
sends.forEach { send ->
appendSendToken(
id = send.id,
keyCipherText = send.key,
)
}
}
builder.build()
}

View File

@ -455,12 +455,37 @@ suspend fun ServerEnvApi.Sends.post(
route = "post-send",
)
suspend fun ServerEnvApi.Sends.Send.get(
httpClient: HttpClient,
env: ServerEnv,
token: String,
) = httpClient
.get(url) {
headers(env)
header("Authorization", "Bearer $token")
attributes.put(routeAttribute, "get-send")
}
.bodyOrApiException<SyncSends>()
suspend fun ServerEnvApi.Sends.Send.put(
httpClient: HttpClient,
env: ServerEnv,
token: String,
body: SendRequest,
) = url.put<SendRequest, SyncSends>(
httpClient = httpClient,
env = env,
token = token,
body = body,
route = "put-send",
)
suspend fun ServerEnvApi.Sends.Send.removePassword(
httpClient: HttpClient,
env: ServerEnv,
token: String,
) = httpClient
.put(url) {
.put(removePassword) {
headers(env)
header("Authorization", "Bearer $token")
attributes.put(routeAttribute, "remove-password-send")

View File

@ -27,6 +27,11 @@ sealed interface BitwardenCrKey {
data class SendToken(
val id: String,
) : BitwardenCrKey
data class CryptoKey(
val symmetricCryptoKey: SymmetricCryptoKey2? = null,
val asymmetricCryptoKey: AsymmetricCryptoKey? = null,
) : BitwardenCrKey
}
interface BitwardenCr {
@ -54,7 +59,7 @@ class BitwardenCrCta(
data class BitwardenCrCtaEnv(
val key: BitwardenCrKey,
/** An encryption type */
val encryptionType: CipherEncryptor.Type,
val encryptionType: CipherEncryptor.Type = CipherEncryptor.Type.AesCbc256_HmacSha256_B64,
)
fun withEnv(env: BitwardenCrCtaEnv) = BitwardenCrCta(
@ -164,9 +169,27 @@ class BitwardenCrImpl(
// Decoder
//
override fun decoder(key: BitwardenCrKey): Decoder = decoders.getValue(key)
override fun decoder(key: BitwardenCrKey): Decoder = when (key) {
is BitwardenCrKey.CryptoKey -> {
cipherEncryptor::decode2
.partially2(key.symmetricCryptoKey)
.partially2(key.asymmetricCryptoKey)
.withExceptionHandling(
key,
symmetricCryptoKey = key.symmetricCryptoKey,
)
}
else -> decoders.getValue(key)
}
override fun encoder(key: BitwardenCrKey): Encoder = encoders.getValue(key)
override fun encoder(key: BitwardenCrKey): Encoder = when (key) {
is BitwardenCrKey.CryptoKey -> {
cipherEncryptor::encode2
.partially3(key.symmetricCryptoKey)
.partially3(key.asymmetricCryptoKey)
}
else -> encoders.getValue(key)
}
override fun cta(
env: BitwardenCrCta.BitwardenCrCtaEnv,
@ -299,37 +322,6 @@ fun BitwardenCrFactoryScope.appendOrganizationToken(
appendEncoder(key, encoder)
}
fun BitwardenCrFactoryScope.appendSendToken(
id: String,
keyCipherText: String,
) {
val symmetricCryptoKey = decoder(BitwardenCrKey.UserToken)(keyCipherText)
.data
.let { keyMaterial ->
val key = cryptoGenerator.hkdf(
seed = keyMaterial,
salt = "bitwarden-send".toByteArray(),
info = "send".toByteArray(),
length = 64,
)
key
}
.let(CryptoKey::decodeSymmetricOrThrow)
val key = BitwardenCrKey.SendToken(id)
val decoder = cipherEncryptor::decode2
.partially2(symmetricCryptoKey)
.partially2(null)
.withExceptionHandling(
key,
symmetricCryptoKey = symmetricCryptoKey,
)
val encoder = cipherEncryptor::encode2
.partially3(symmetricCryptoKey)
.partially3(null)
appendDecoder(key, decoder)
appendEncoder(key, encoder)
}
fun BitwardenCrFactoryScope.appendOrganizationToken2(
id: String,
keyData: ByteArray,
@ -349,3 +341,20 @@ fun BitwardenCrFactoryScope.appendOrganizationToken2(
appendDecoder(key, decoder)
appendEncoder(key, encoder)
}
//
// Sends
//
fun CryptoGenerator.makeSendCryptoKeyMaterial() = seed(length = 16)
fun CryptoGenerator.makeSendCryptoKey(
keyMaterial: ByteArray = makeSendCryptoKeyMaterial(),
): ByteArray {
return hkdf(
seed = keyMaterial,
salt = "bitwarden-send".toByteArray(),
info = "send".toByteArray(),
length = 64,
)
}

View File

@ -82,7 +82,7 @@ fun List<BitwardenCipher.Login.Uri>.transform(
fun BitwardenCipher.Login.Uri.transform(
crypto: BitwardenCrCta,
) = copy(
uri = crypto.transformString(uri),
uri = crypto.transformString(uri.orEmpty()),
)
@JvmName("encryptListOfBitwardenCipherLoginFido2Credentials")

View File

@ -3,17 +3,17 @@ package com.artemchep.keyguard.provider.bitwarden.crypto
import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend
fun BitwardenSend.transform(
crypto: BitwardenCrCta,
codec2: BitwardenCrCta,
itemCrypto: BitwardenCrCta,
globalCrypto: BitwardenCrCta,
) = copy(
// common
// key is encoded with profile key
keyBase64 = keyBase64?.let(codec2::transformBase64),
name = crypto.transformString(name),
notes = crypto.transformString(notes),
keyBase64 = keyBase64?.let(globalCrypto::transformBase64),
name = itemCrypto.transformString(name),
notes = itemCrypto.transformString(notes),
// types
text = text?.transform(crypto),
file = file?.transform(crypto),
text = text?.transform(itemCrypto),
file = file?.transform(itemCrypto),
)
fun BitwardenSend.File.transform(
@ -26,5 +26,5 @@ fun BitwardenSend.File.transform(
fun BitwardenSend.Text.transform(
crypto: BitwardenCrCta,
) = copy(
text = crypto.transformString(text),
text = crypto.transformString(requireNotNull(text)),
)

View File

@ -20,7 +20,7 @@ fun LoginUriRequest.Companion.of(
model: BitwardenCipher.Login.Uri,
) = kotlin.run {
LoginUriRequest(
uri = model.uri.orEmpty(),
uri = requireNotNull(model.uri) { "Login URI request must have a non-null URI!" },
match = model.match?.let(UriMatchTypeEntity::of),
)
}

View File

@ -1,12 +1,20 @@
package com.artemchep.keyguard.provider.bitwarden.entity.request
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.text.Base64Service
import com.artemchep.keyguard.core.store.bitwarden.BitwardenOptionalStringNullable
import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend
import com.artemchep.keyguard.provider.bitwarden.entity.SendTypeEntity
import com.artemchep.keyguard.provider.bitwarden.entity.of
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SendRequest(
@SerialName("key")
val key: String,
@SerialName("type")
val type: SendTypeEntity,
@SerialName("name")
@ -33,27 +41,92 @@ data class SendRequest(
val text: SendTextRequest?,
@SerialName("file")
val file: SendFileRequest?,
)
) {
companion object
}
@Serializable
data class SendTextRequest(
@SerialName("name")
val name: String,
@SerialName("notes")
val notes: String,
@SerialName("password")
val password: String? = null,
val type: SendTypeEntity,
)
@SerialName("text")
val text: String,
@SerialName("hidden")
val hidden: Boolean,
) {
companion object
}
@Serializable
data class SendFileRequest(
@SerialName("name")
val name: String,
@SerialName("notes")
val notes: String,
@SerialName("password")
val password: String? = null,
val type: SendTypeEntity,
)
@SerialName("fileName")
val fileName: String? = null,
) {
companion object
}
context(CryptoGenerator, Base64Service)
fun SendRequest.Companion.of(
model: BitwardenSend,
key: ByteArray,
) = kotlin.run {
val type = SendTypeEntity.of(model.type)
val text = model.text
?.let(SendTextRequest::of)
val file = model.file
?.let(SendFileRequest::of)
val deletionDate = model.deletedDate
?: Clock.System.now()
val passwordHashBase64 = when (val pwd = model.changes?.passwordBase64) {
is BitwardenOptionalStringNullable.Some -> run {
pwd.value
// Bitwarden doesn't allow us to remove the password
// within the PUT request.
?: return@run null
val password = decode(pwd.value)
// Send the hash code of that password, instead of
// sending the actual password.
val passwordHash = pbkdf2(
seed = password,
salt = key,
iterations = 100_000,
)
encodeToString(passwordHash)
}
is BitwardenOptionalStringNullable.None,
null,
-> null
}
val keyBase64 = requireNotNull(model.keyBase64) { "Send request must have a cipher key!" }
SendRequest(
type = type,
key = keyBase64,
name = requireNotNull(model.name) { "Send request must have a non-null name!" },
notes = model.notes,
password = passwordHashBase64,
disabled = model.disabled,
hideEmail = model.hideEmail == true,
deletionDate = deletionDate,
expirationDate = model.expirationDate,
maxAccessCount = model.maxAccessCount,
text = text,
file = file,
)
}
fun SendTextRequest.Companion.of(
model: BitwardenSend.Text,
) = kotlin.run {
SendTextRequest(
text = model.text.orEmpty(),
hidden = model.hidden == true,
)
}
fun SendFileRequest.Companion.of(
model: BitwardenSend.File,
) = kotlin.run {
SendFileRequest(
fileName = model.fileName,
)
}

View File

@ -0,0 +1,49 @@
package com.artemchep.keyguard.provider.bitwarden.entity.request
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.text.Base64Service
import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend
sealed interface SendUpdate {
companion object;
val source: BitwardenSend
data class Modify(
override val source: BitwardenSend,
val sendId: String,
val sendRequest: SendRequest,
) : SendUpdate
data class Create(
override val source: BitwardenSend,
val sendRequest: SendRequest,
) : SendUpdate
}
context(CryptoGenerator, Base64Service)
fun SendUpdate.Companion.of(
model: BitwardenSend,
key: ByteArray,
) = when {
model.service.remote != null -> {
SendUpdate.Modify(
source = model,
sendId = model.service.remote.id,
sendRequest = SendRequest.of(
model = model,
key = key,
),
)
}
else -> {
SendUpdate.Create(
source = model,
sendRequest = SendRequest.of(
model = model,
key = key,
),
)
}
}

View File

@ -1,6 +1,7 @@
package com.artemchep.keyguard.provider.bitwarden.mapper
import com.artemchep.keyguard.common.model.DSend
import com.artemchep.keyguard.core.store.bitwarden.BitwardenOptionalStringNullable
import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend
suspend fun BitwardenSend.toDomain(
@ -25,9 +26,14 @@ suspend fun BitwardenSend.toDomain(
notes = notes.orEmpty(),
accessCount = accessCount,
maxAccessCount = maxAccessCount,
hasPassword = changes?.passwordBase64
?.let { it as? BitwardenOptionalStringNullable.Some }
?.let { it.value != null }
?: (password != null),
synced = !service.deleted &&
revisionDate == service.remote?.revisionDate,
disabled = disabled,
hideEmail = hideEmail ?: false,
password = password,
// types
type = type,
text = text?.toDomain(),

View File

@ -0,0 +1,209 @@
package com.artemchep.keyguard.provider.bitwarden.usecase
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.attempt
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.combine
import com.artemchep.keyguard.common.io.effectMap
import com.artemchep.keyguard.common.io.flatMap
import com.artemchep.keyguard.common.io.flatTap
import com.artemchep.keyguard.common.io.io
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.io.ioUnit
import com.artemchep.keyguard.common.io.map
import com.artemchep.keyguard.common.model.AccountId
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.common.model.DSend
import com.artemchep.keyguard.common.model.canDelete
import com.artemchep.keyguard.common.model.create.CreateRequest
import com.artemchep.keyguard.common.model.create.CreateSendRequest
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.text.Base64Service
import com.artemchep.keyguard.common.usecase.AddFolder
import com.artemchep.keyguard.common.usecase.AddSend
import com.artemchep.keyguard.common.usecase.GetPasswordStrength
import com.artemchep.keyguard.common.usecase.TrashCipherById
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend
import com.artemchep.keyguard.core.store.bitwarden.BitwardenService
import com.artemchep.keyguard.provider.bitwarden.crypto.makeSendCryptoKey
import com.artemchep.keyguard.provider.bitwarden.crypto.makeSendCryptoKeyMaterial
import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifyDatabase
import kotlinx.collections.immutable.toPersistentList
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import org.kodein.di.DirectDI
import org.kodein.di.instance
import kotlin.time.Duration
/**
* @author Artem Chepurnyi
*/
class AddSendImpl(
private val modifyDatabase: ModifyDatabase,
private val cryptoGenerator: CryptoGenerator,
private val base64Service: Base64Service,
) : AddSend {
companion object {
private const val TAG = "AddSend.bitwarden"
}
constructor(directDI: DirectDI) : this(
modifyDatabase = directDI.instance(),
cryptoGenerator = directDI.instance(),
base64Service = directDI.instance(),
)
override fun invoke(
sendIdsToRequests: Map<String?, CreateSendRequest>,
): IO<List<String>> = ioEffect {
sendIdsToRequests
}.flatMap { map ->
modifyDatabase { database ->
val dao = database.sendQueries
val now = Clock.System.now()
val oldSendsMap = map
.keys
.filterNotNull()
.mapNotNull { sendId ->
dao
.getBySendId(sendId)
.executeAsOneOrNull()
}
.associateBy { it.sendId }
val models = map
.map { (sendId, request) ->
val old = oldSendsMap[sendId]?.data_
BitwardenSend.of(
cryptoGenerator = cryptoGenerator,
base64Service = base64Service,
now = now,
request = request,
old = old,
)
}
if (models.isEmpty()) {
return@modifyDatabase ModifyDatabase.Result(
changedAccountIds = emptySet(),
value = emptyList(),
)
}
dao.transaction {
models.forEach { send ->
dao.insert(
sendId = send.sendId,
accountId = send.accountId,
data = send,
)
}
}
val changedAccountIds = models
.map { AccountId(it.accountId) }
.toSet()
ModifyDatabase.Result(
changedAccountIds = changedAccountIds,
value = models
.map { it.sendId },
)
}
}
}
private suspend fun BitwardenSend.Companion.of(
cryptoGenerator: CryptoGenerator,
base64Service: Base64Service,
request: CreateSendRequest,
now: Instant,
old: BitwardenSend? = null,
): BitwardenSend {
val accountId = request.ownership?.accountId
require(old?.service?.deleted != true) {
"Can not modify deleted send!"
}
requireNotNull(accountId) { "Send must have an account!" }
val type = when (request.type) {
DSend.Type.Text -> BitwardenSend.Type.Text
DSend.Type.File -> BitwardenSend.Type.File
null,
DSend.Type.None,
-> error("Send must have a type!")
}
var text: BitwardenSend.Text? = null
var file: BitwardenSend.File? = null
when (type) {
BitwardenSend.Type.Text -> {
text = BitwardenSend.Text.of(
request = request,
old = old,
)
}
BitwardenSend.Type.File -> {
file = BitwardenSend.File.of(
request = request,
)
}
BitwardenSend.Type.None -> {
error("Send must have a type!")
}
}
val keyBase64 = old?.keyBase64
?: run {
val key = cryptoGenerator.makeSendCryptoKeyMaterial()
base64Service.encodeToString(key)
}
val cipherId = old?.sendId
?: cryptoGenerator.uuid()
val createdDate = old?.createdDate ?: request.now
val deletedDate = old?.deletedDate ?: request.now.plus(with(Duration) {3L.days})
return BitwardenSend(
accountId = accountId,
sendId = cipherId,
accessId = "",
revisionDate = now,
createdDate = createdDate,
deletedDate = deletedDate,
// service fields
service = BitwardenService(
remote = old?.service?.remote,
deleted = false,
version = BitwardenService.VERSION,
),
// common
keyBase64 = keyBase64,
name = request.title,
notes = request.note,
accessCount = 0,
maxAccessCount = null,
password = null,
disabled = false,
hideEmail = null,
// types
type = type,
text = text,
file = file,
)
}
private suspend fun BitwardenSend.Text.Companion.of(
request: CreateSendRequest,
old: BitwardenSend? = null,
): BitwardenSend.Text {
return BitwardenSend.Text(
text = request.text.text,
hidden = request.text.hidden,
)
}
private suspend fun BitwardenSend.File.Companion.of(
request: CreateSendRequest,
): BitwardenSend.File {
TODO()
}

View File

@ -0,0 +1,70 @@
package com.artemchep.keyguard.provider.bitwarden.usecase
import arrow.core.getOrElse
import com.artemchep.keyguard.common.io.map
import com.artemchep.keyguard.common.model.PatchSendRequest
import com.artemchep.keyguard.common.service.text.Base64Service
import com.artemchep.keyguard.common.usecase.PatchSendById
import com.artemchep.keyguard.core.store.bitwarden.BitwardenOptionalStringNullable
import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend
import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifySendById
import org.kodein.di.DirectDI
import org.kodein.di.instance
/**
* @author Artem Chepurnyi
*/
class PatchSendByIdImpl(
private val modifySendById: ModifySendById,
private val base64Service: Base64Service,
) : PatchSendById {
companion object {
private const val TAG = "PatchSendById.bitwarden"
}
constructor(directDI: DirectDI) : this(
modifySendById = directDI.instance(),
base64Service = directDI.instance(),
)
override fun invoke(
patch: PatchSendRequest,
) = performPatchSend(
patch = patch,
).map { true }
private fun performPatchSend(
patch: PatchSendRequest,
) = modifySendById(
sendIds = patch.patch.keys,
) { model ->
val p = patch.patch.getValue(model.sendId)
val data = model.data_
.run {
val passwordBase64 = p.password
.map { newPassword ->
val newPasswordBase64 = newPassword
?.let(base64Service::encodeToString)
newPasswordBase64.let(BitwardenOptionalStringNullable::Some)
}
.getOrElse { BitwardenOptionalStringNullable.None }
copy(
name = p.name.getOrElse { name },
hideEmail = p.hideEmail.getOrElse { hideEmail },
disabled = p.disabled.getOrElse { disabled },
file = p.fileName
.map { fileName ->
file?.copy(
fileName = fileName,
)
}
.getOrElse { file },
changes = (changes ?: BitwardenSend.Changes()).copy(
passwordBase64 = passwordBase64,
),
)
}
model.copy(data_ = data)
}
}

View File

@ -0,0 +1,45 @@
package com.artemchep.keyguard.provider.bitwarden.usecase
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.map
import com.artemchep.keyguard.common.usecase.RemoveSendById
import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend
import com.artemchep.keyguard.core.store.bitwarden.deleted
import com.artemchep.keyguard.core.store.bitwarden.service
import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifySendById
import org.kodein.di.DirectDI
import org.kodein.di.instance
/**
* @author Artem Chepurnyi
*/
class RemoveSendByIdImpl(
private val modifySendById: ModifySendById,
) : RemoveSendById {
companion object {
private const val TAG = "RemoveSendById.bitwarden"
}
constructor(directDI: DirectDI) : this(
modifySendById = directDI.instance(),
)
override fun invoke(
sendIds: Set<String>,
): IO<Unit> = performRemoveSend(
sendIds = sendIds,
).map { Unit }
private fun performRemoveSend(
sendIds: Set<String>,
) = modifySendById(
sendIds = sendIds,
checkIfStub = false, // we want to be able to delete failed items
) { model ->
var new = model
new = new.copy(
data_ = BitwardenSend.service.deleted.set(new.data_, true),
)
new
}
}

View File

@ -0,0 +1,90 @@
package com.artemchep.keyguard.provider.bitwarden.usecase.util
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.model.AccountId
import com.artemchep.keyguard.data.bitwarden.Send
import kotlinx.datetime.Clock
import org.kodein.di.DirectDI
import org.kodein.di.instance
/**
* @author Artem Chepurnyi
*/
class ModifySendById(
private val modifyDatabase: ModifyDatabase,
) {
companion object {
private const val TAG = "ModifySendById.bitwarden"
}
constructor(directDI: DirectDI) : this(
modifyDatabase = directDI.instance(),
)
operator fun invoke(
sendIds: Set<String>,
checkIfStub: Boolean = true,
checkIfChanged: Boolean = true,
updateRevisionDate: Boolean = true,
transform: suspend (Send) -> Send,
): IO<Set<String>> = modifyDatabase { database ->
val dao = database.sendQueries
val now = Clock.System.now()
val models = dao
.get()
.executeAsList()
.filter {
it.sendId in sendIds
}
.mapNotNull { model ->
// If the send was not properly decoded, then
// prevent it from being pushed to backend.
val service = model.data_.service
if (checkIfStub && !service.canEdit()) {
return@mapNotNull null
}
var new = model
new = transform(new)
// If the cipher was not changed, then we do not need to
// update it in the database.
if (checkIfChanged && new == model) {
return@mapNotNull null
}
if (updateRevisionDate) {
new = new.copy(
data_ = new.data_.copy(
revisionDate = now,
),
)
}
new
}
if (models.isEmpty()) {
return@modifyDatabase ModifyDatabase.Result(
changedAccountIds = emptySet(),
value = emptySet(),
)
}
dao.transaction {
models.forEach { send ->
dao.insert(
sendId = send.sendId,
accountId = send.accountId,
data = send.data_,
)
}
}
val changedAccountIds = models
.map { AccountId(it.accountId) }
.toSet()
val changedSendIds = models
.map { it.sendId }
.toSet()
ModifyDatabase.Result(
changedAccountIds = changedAccountIds,
value = changedSendIds,
)
}
}

View File

@ -58,6 +58,7 @@
<string name="postal_code">Postal code</string>
<string name="pull_to_search">Pull to search</string>
<string name="save">Save</string>
<string name="save_to">Save to</string>
<string name="uri">URI</string>
<string name="uris">URIs</string>
<string name="note">Note</string>
@ -248,9 +249,35 @@
<string name="ciphers_action_trash_title">Trash</string>
<string name="ciphers_action_restore_title">Restore</string>
<string name="ciphers_action_delete_title">Delete forever</string>
<string name="ciphers_action_delete_confirmation_title">Delete forever?</string>
<string name="ciphers_action_configure_watchtower_alerts_title">Configure Watchtower alerts</string>
<string name="ciphers_action_cascade_trash_associated_items_title">Move associated items to trash</string>
<string name="sends_action_change_name_title">Change name</string>
<string name="sends_action_change_names_title">Change names</string>
<string name="sends_action_change_filename_title">Change file name</string>
<string name="sends_action_change_filenames_title">Change file names</string>
<string name="sends_action_set_password_title">Set password</string>
<string name="sends_action_set_passwords_title">Set passwords</string>
<string name="sends_action_set_password_confirmation_message">Require a password for users to access this item</string>
<string name="sends_action_change_password_title">Change password</string>
<string name="sends_action_change_passwords_title">Change passwords</string>
<string name="sends_action_remove_password_title">Remove password</string>
<string name="sends_action_remove_passwords_title">Remove passwords</string>
<string name="sends_action_remove_password_confirmation_title">Remove password?</string>
<string name="sends_action_remove_passwords_confirmation_title">Remove passwords?</string>
<string name="sends_action_remove_password_confirmation_message">Do not require a password for users to access this item</string>
<string name="sends_action_hide_email_title">Hide email</string>
<string name="sends_action_show_email_title">Show email</string>
<string name="sends_action_enable_title">Activate</string>
<string name="sends_action_enable_text">Active send is accessible by a public URL</string>
<string name="sends_action_enable_confirmation_title">Activate?</string>
<string name="sends_action_disable_title">Deactivate</string>
<string name="sends_action_disable_text">Inactive send is inaccessible by a public URL</string>
<string name="sends_action_disable_confirmation_title">Deactivate?</string>
<string name="sends_action_delete_title">Delete forever</string>
<string name="sends_action_delete_confirmation_title">Delete forever?</string>
<string name="file_action_open_with_title">Open with…</string>
<string name="file_action_send_with_title">Send with…</string>
<string name="file_action_open_in_file_manager_title">Open in a file manager</string>
@ -577,6 +604,9 @@
<string name="additem_auth_reprompt_title">Auth re-prompt</string>
<string name="additem_auth_reprompt_text">Ask to authenticate again when you view or autofill a cipher</string>
<string name="addsend_header_new_title">New item</string>
<string name="addsend_header_edit_title">Edit item</string>
<string name="addaccount_header_title">Add Bitwarden account</string>
<string name="addaccount_disclaimer_bitwarden_label">We are not affiliated, associated, authorized, endorsed by, or in any way officially connected with the Bitwarden, Inc., or any of its subsidiaries or its affiliates.</string>
<!--