feat: Export account

This commit is contained in:
Artem Chepurnoy 2024-02-26 15:50:51 +02:00
parent 2ef74f5310
commit 63a2cfa8fa
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
60 changed files with 2057 additions and 24 deletions

View File

@ -80,6 +80,7 @@ kotlin {
val jvmMain by creating {
dependsOn(commonMain)
dependencies {
implementation(libs.lingala.zip4j)
implementation(libs.nulabinc.zxcvbn)
implementation(libs.commons.codec)
implementation(libs.bouncycastle.bcpkix)

View File

@ -19,6 +19,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.CAMERA" />
<!--
I need this permission to write files (export vault) to the
external downloads directory.
-->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<!-- Vibrate on long click -->
<uses-permission android:name="android.permission.VIBRATE" />

View File

@ -10,4 +10,5 @@ actual enum class Permission(
val maxSdk: Int = Int.MAX_VALUE,
) {
POST_NOTIFICATIONS(Manifest.permission.POST_NOTIFICATIONS, minSdk = 33),
WRITE_EXTERNAL_STORAGE(Manifest.permission.WRITE_EXTERNAL_STORAGE, maxSdk = 29),
}

View File

@ -0,0 +1,122 @@
package com.artemchep.keyguard.copy
import android.app.Application
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.service.dirs.DirsService
import kotlinx.coroutines.Dispatchers
import org.kodein.di.DirectDI
import org.kodein.di.instance
import java.io.OutputStream
class DirsServiceAndroid(
private val context: Context,
) : DirsService {
constructor(
directDI: DirectDI,
) : this(
context = directDI.instance<Application>(),
)
override fun saveToDownloads(
fileName: String,
write: suspend (OutputStream) -> Unit,
): IO<Unit> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveToDownloadsApi29(
fileName = fileName,
write = write,
)
} else {
saveToDownloadsApi26(
fileName = fileName,
write = write,
)
}
private fun saveToDownloadsApi26(
fileName: String,
write: suspend (OutputStream) -> Unit,
) = ioEffect {
val downloadsDir = Environment
.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val file = downloadsDir.resolve(fileName)
file.outputStream()
.use {
write(it)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveToDownloadsApi29(
fileName: String,
write: suspend (OutputStream) -> Unit,
) = ioEffect {
val mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(fileName.substringAfterLast('.'))
ah(
write = write,
) {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
if (mimeType != null) {
put(MediaStore.Images.Media.MIME_TYPE, mimeType)
}
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
// Save to external downloads
MediaStore.Downloads.EXTERNAL_CONTENT_URI
}.bind()
Unit
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun ah(
write: suspend (OutputStream) -> Unit,
configure: ContentValues.() -> Uri,
) = ioEffect {
val contentResolver = context.contentResolver
val values = ContentValues().apply {
put(MediaStore.MediaColumns.IS_PENDING, true)
}
val contentUri = values.run(configure)
val fileUri = contentResolver.insert(contentUri, values)
requireNotNull(fileUri)
try {
// Try to save the file into the
// give directory.
val os = contentResolver
.openOutputStream(fileUri)
requireNotNull(os) {
"File output stream is null."
}
println("Before export")
os.use { outputStream ->
write(outputStream)
}
// Update the record, stating that we have completed
// saving the file.
values.put(MediaStore.MediaColumns.IS_PENDING, false)
contentResolver.update(fileUri, values, null, null)
} catch (e: Exception) {
e.printStackTrace()
contentResolver.delete(fileUri, null, null)
}
}
// Checks if a volume containing external storage is available
// for read and write.
private fun isExternalStorageWritable() =
Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED
}

View File

@ -12,6 +12,7 @@ import com.artemchep.keyguard.common.service.Files
import com.artemchep.keyguard.common.service.autofill.AutofillService
import com.artemchep.keyguard.common.service.clipboard.ClipboardService
import com.artemchep.keyguard.common.service.connectivity.ConnectivityService
import com.artemchep.keyguard.common.service.dirs.DirsService
import com.artemchep.keyguard.common.service.download.DownloadManager
import com.artemchep.keyguard.common.service.keyvalue.KeyValueStore
import com.artemchep.keyguard.common.service.logging.LogRepository
@ -44,6 +45,7 @@ import com.artemchep.keyguard.copy.ConnectivityServiceAndroid
import com.artemchep.keyguard.copy.GetBarcodeImageJvm
import com.artemchep.keyguard.copy.LinkInfoExtractorAndroid
import com.artemchep.keyguard.common.service.extract.impl.LinkInfoExtractorExecute
import com.artemchep.keyguard.copy.DirsServiceAndroid
import com.artemchep.keyguard.copy.LinkInfoExtractorLaunch
import com.artemchep.keyguard.copy.LogRepositoryAndroid
import com.artemchep.keyguard.copy.PermissionServiceAndroid
@ -143,6 +145,9 @@ fun diFingerprintRepositoryModule() = DI.Module(
bindSingleton<ConnectivityService> {
ConnectivityServiceAndroid(this)
}
bindSingleton<DirsService> {
DirsServiceAndroid(this)
}
bindSingleton<PowerService> {
PowerServiceAndroid(this)
}

View File

@ -72,10 +72,11 @@ fun settingPermissionProvider(
leading: @Composable RowScope.() -> Unit,
title: StringResource,
text: StringResource,
minSdk: Int,
minSdk: Int = Int.MIN_VALUE,
maxSdk: Int = Int.MAX_VALUE,
permissionProvider: () -> String,
): SettingComponent = kotlin.run {
if (Build.VERSION.SDK_INT >= minSdk) {
if (Build.VERSION.SDK_INT in minSdk..maxSdk) {
kotlin.run {
val item = SettingIi {
val permissionState = rememberPermissionState(permissionProvider())
@ -102,18 +103,7 @@ fun settingPermissionProvider(
flowOf(item)
}
} else {
kotlin.run {
val item = SettingIi {
SettingPermission(
leading = leading,
title = stringResource(title),
text = stringResource(text),
checked = true,
onCheckedChange = null,
)
}
flowOf(item)
}
flowOf(null)
}
}

View File

@ -0,0 +1,26 @@
package com.artemchep.keyguard.feature.home.settings.component
import android.Manifest
import android.os.Build
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Storage
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.icons.icon
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import org.kodein.di.DirectDI
actual fun settingPermissionWriteExternalStorageProvider(
directDI: DirectDI,
): SettingComponent = settingPermissionWriteExternalStorageProvider()
@OptIn(ExperimentalPermissionsApi::class)
fun settingPermissionWriteExternalStorageProvider(): SettingComponent = settingPermissionProvider(
leading = icon<RowScope>(Icons.Outlined.Storage),
title = Res.strings.pref_item_permission_write_external_storage_title,
text = Res.strings.pref_item_permission_write_external_storage_text,
maxSdk = Build.VERSION_CODES.Q,
permissionProvider = {
Manifest.permission.WRITE_EXTERNAL_STORAGE
},
)

View File

@ -4,6 +4,7 @@ import kotlinx.datetime.Instant
data class DCollection(
val id: String,
val externalId: String?,
val organizationId: String?,
val accountId: String,
val revisionDate: Instant,

View File

@ -201,6 +201,38 @@ sealed interface DFilter {
}
}
@Serializable
@SerialName("not")
data class Not(
val filter: DFilter,
) : DFilter {
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
) = kotlin.run {
val predicate = filter.prepare(
directDI = directDI,
ciphers = ciphers,
)
return@run { cipher: DSecret ->
!predicate(cipher)
}
}
override suspend fun prepareFolders(
directDI: DirectDI,
folders: List<DFolder>,
) = kotlin.run {
val predicate = filter.prepareFolders(
directDI = directDI,
folders = folders,
)
return@run { folder: DFolder ->
!predicate(folder)
}
}
}
@Serializable
@SerialName("all")
data object All : DFilter {
@ -242,6 +274,9 @@ sealed interface DFilter {
@SerialName("organization")
ORGANIZATION,
@SerialName("cipher")
CIPHER,
}
override suspend fun prepare(
@ -270,6 +305,7 @@ sealed interface DFilter {
What.ACCOUNT -> cipher.accountId
What.FOLDER -> cipher.folderId
What.ORGANIZATION -> cipher.organizationId
What.CIPHER -> cipher.id
} == id
}
@ -281,6 +317,7 @@ sealed interface DFilter {
What.ACCOUNT -> folder.accountId
What.COLLECTION,
What.ORGANIZATION,
What.CIPHER,
-> {
return@run true
}

View File

@ -0,0 +1,11 @@
package com.artemchep.keyguard.common.service.dirs
import com.artemchep.keyguard.common.io.IO
import java.io.OutputStream
interface DirsService {
fun saveToDownloads(
fileName: String,
write: suspend (OutputStream) -> Unit,
): IO<Unit>
}

View File

@ -0,0 +1,15 @@
package com.artemchep.keyguard.common.service.export
import com.artemchep.keyguard.common.model.DCollection
import com.artemchep.keyguard.common.model.DFolder
import com.artemchep.keyguard.common.model.DOrganization
import com.artemchep.keyguard.common.model.DSecret
interface ExportService {
fun export(
organizations: List<DOrganization>,
collections: List<DCollection>,
folders: List<DFolder>,
ciphers: List<DSecret>,
): String
}

View File

@ -0,0 +1,11 @@
package com.artemchep.keyguard.common.service.export.entity
import kotlinx.serialization.Serializable
@Serializable
data class CollectionExportEntity(
val id: String,
val name: String,
val organizationId: String?,
val externalId: String?,
)

View File

@ -0,0 +1,9 @@
package com.artemchep.keyguard.common.service.export.entity
import kotlinx.serialization.Serializable
@Serializable
data class FolderExportEntity(
val id: String,
val name: String,
)

View File

@ -0,0 +1,13 @@
package com.artemchep.keyguard.common.service.export.entity
import kotlinx.serialization.Serializable
@Serializable
data class ItemCardExportEntity(
val cardholderName: String? = null,
val brand: String? = null,
val number: String? = null,
val expMonth: String? = null,
val expYear: String? = null,
val code: String? = null,
)

View File

@ -0,0 +1,13 @@
package com.artemchep.keyguard.common.service.export.entity
import com.artemchep.keyguard.provider.bitwarden.entity.FieldTypeEntity
import com.artemchep.keyguard.provider.bitwarden.entity.LinkedIdTypeEntity
import kotlinx.serialization.Serializable
@Serializable
data class ItemFieldExportEntity(
val type: FieldTypeEntity,
val name: String? = null,
val value: String? = null,
val linkedId: LinkedIdTypeEntity? = null,
)

View File

@ -0,0 +1,25 @@
package com.artemchep.keyguard.common.service.export.entity
import kotlinx.serialization.Serializable
@Serializable
data class ItemIdentityExportEntity(
val title: String? = null,
val firstName: String? = null,
val middleName: String? = null,
val lastName: String? = null,
val address1: String? = null,
val address2: String? = null,
val address3: String? = null,
val city: String? = null,
val state: String? = null,
val postalCode: String? = null,
val country: String? = null,
val company: String? = null,
val email: String? = null,
val phone: String? = null,
val ssn: String? = null,
val username: String? = null,
val passportNumber: String? = null,
val licenseNumber: String? = null,
)

View File

@ -0,0 +1,38 @@
package com.artemchep.keyguard.common.service.export.entity
import com.artemchep.keyguard.provider.bitwarden.entity.UriMatchTypeEntity
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
data class ItemLoginExportEntity(
val uris: List<ItemLoginUriExportEntity>? = null,
val username: String? = null,
val password: String? = null,
val passwordRevisionDate: Instant? = null,
val totp: String? = null,
val fido2Credentials: List<ItemLoginFido2CredentialsExportEntity>? = null,
)
@Serializable
data class ItemLoginUriExportEntity(
val uri: String? = null,
val match: UriMatchTypeEntity? = null,
)
@Serializable
data class ItemLoginFido2CredentialsExportEntity(
val credentialId: String? = null,
val keyType: String,
val keyAlgorithm: String,
val keyCurve: String,
val keyValue: String,
val rpId: String,
val rpName: String,
val counter: String,
val userHandle: String,
val userName: String? = null,
val userDisplayName: String? = null,
val discoverable: String,
val creationDate: Instant,
)

View File

@ -0,0 +1,10 @@
package com.artemchep.keyguard.common.service.export.entity
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
data class ItemLoginPasswordHistoryExportEntity(
val lastUsedDate: Instant?,
val password: String,
)

View File

@ -0,0 +1,9 @@
package com.artemchep.keyguard.common.service.export.entity
import kotlinx.serialization.Serializable
@Serializable
data class OrganizationExportEntity(
val id: String,
val name: String,
)

View File

@ -0,0 +1,13 @@
package com.artemchep.keyguard.common.service.export.entity
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class RootExportEntity(
val encrypted: Boolean,
val organizations: List<OrganizationExportEntity> = emptyList(),
val collections: List<CollectionExportEntity> = emptyList(),
val folders: List<FolderExportEntity> = emptyList(),
val items: List<JsonObject>,
)

View File

@ -0,0 +1,301 @@
package com.artemchep.keyguard.common.service.export.impl
import com.artemchep.keyguard.common.model.DCollection
import com.artemchep.keyguard.common.model.DFolder
import com.artemchep.keyguard.common.model.DOrganization
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.common.service.export.ExportService
import com.artemchep.keyguard.common.service.export.entity.CollectionExportEntity
import com.artemchep.keyguard.common.service.export.entity.ItemFieldExportEntity
import com.artemchep.keyguard.common.service.export.entity.FolderExportEntity
import com.artemchep.keyguard.common.service.export.entity.ItemCardExportEntity
import com.artemchep.keyguard.common.service.export.entity.ItemIdentityExportEntity
import com.artemchep.keyguard.common.service.export.entity.ItemLoginExportEntity
import com.artemchep.keyguard.common.service.export.entity.ItemLoginFido2CredentialsExportEntity
import com.artemchep.keyguard.common.service.export.entity.ItemLoginPasswordHistoryExportEntity
import com.artemchep.keyguard.common.service.export.entity.ItemLoginUriExportEntity
import com.artemchep.keyguard.common.service.export.entity.OrganizationExportEntity
import com.artemchep.keyguard.common.service.export.entity.RootExportEntity
import com.artemchep.keyguard.common.util.int
import com.artemchep.keyguard.provider.bitwarden.entity.CipherTypeEntity
import com.artemchep.keyguard.provider.bitwarden.entity.FieldTypeEntity
import com.artemchep.keyguard.provider.bitwarden.entity.LinkedIdTypeEntity
import com.artemchep.keyguard.provider.bitwarden.entity.UriMatchTypeEntity
import com.artemchep.keyguard.provider.bitwarden.entity.of
import kotlinx.datetime.Instant
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import org.kodein.di.DirectDI
import org.kodein.di.instance
class ExportServiceImpl(
private val json: Json,
) : ExportService {
constructor(
directDI: DirectDI,
) : this(
json = directDI.instance(),
)
override fun export(
organizations: List<DOrganization>,
collections: List<DCollection>,
folders: List<DFolder>,
ciphers: List<DSecret>,
): String {
val localToRemoteFolderIdMap = folders
.mapNotNull { folder ->
val remoteId = folder.service.remote?.id
?: return@mapNotNull null
folder.id to remoteId
}
.toMap()
val exportedOrganizations = organizations
.map { organization ->
OrganizationExportEntity(
id = organization.id,
name = organization.name,
)
}
val exportedCollections = collections
.map { collection ->
CollectionExportEntity(
id = collection.id,
name = collection.name,
organizationId = collection.organizationId,
externalId = collection.externalId,
)
}
val exportedFolders = folders
.map { folder ->
FolderExportEntity(
id = folder.service.remote?.id
?: folder.id,
name = folder.name,
)
}
val exportedItems = ciphers
.filter { it.organizationId == null }
.map { cipher ->
cipher.toExportEntity(
localToRemoteFolderIdMap = localToRemoteFolderIdMap,
)
}
val entity = RootExportEntity(
encrypted = false,
organizations = exportedOrganizations,
collections = exportedCollections,
folders = exportedFolders,
items = exportedItems,
)
return json.encodeToString(entity)
}
@OptIn(ExperimentalSerializationApi::class)
private fun DSecret.toExportEntity(
localToRemoteFolderIdMap: Map<String, String>,
): JsonObject = buildJsonObject {
val cipherId = service.remote?.id
?: id
put("id", cipherId)
// Map the local identifiers to their remote counterparts. This
// ensures that the entries can be correctly restores later.
val folderId = folderId
?.let { id ->
localToRemoteFolderIdMap.getOrDefault(id, id)
}
put("folderId", folderId)
// Organizations and collections.
put("organizationId", organizationId)
run {
val key = "collectionIds"
if (collectionIds.isNotEmpty()) {
putJsonArray(key) {
collectionIds.forEach { collectionId ->
add(collectionId)
}
}
} else {
put(key, null)
}
}
//
// Common
//
put("name", name)
put("notes", notes)
put("favorite", favorite)
put("reprompt", reprompt.int)
// Dates
fun put(key: String, instant: Instant?) =
put(
key = key,
element = instant?.let(json::encodeToJsonElement) ?: JsonNull,
)
put("revisionDate", revisionDate)
put("creationDate", instant = createdDate ?: revisionDate)
put("deletedDate", deletedDate)
//
// Type
//
val itemType = CipherTypeEntity.of(type)
if (itemType != null) {
val value = json.encodeToJsonElement(itemType)
put("type", value)
}
run {
val key = "fields"
val list = fields
.map { field ->
val type = FieldTypeEntity.of(field.type)
val linkedId = field.linkedId?.let(LinkedIdTypeEntity::of)
ItemFieldExportEntity(
type = type,
name = field.name,
value = field.value,
linkedId = linkedId,
)
}
if (list.isNotEmpty()) {
putJsonArray(
key = key,
) {
list.forEach { item ->
val el = json.encodeToJsonElement(item)
add(el)
}
}
} else {
put(key, JsonNull)
}
}
// Password history for historical reasons is not contained in the
// login object. It also should always be in the json.
run {
val key = "passwordHistory"
val list = login
?.passwordHistory
?.map { item ->
ItemLoginPasswordHistoryExportEntity(
lastUsedDate = item.lastUsedDate,
password = item.password,
)
}
if (!list.isNullOrEmpty()) {
putJsonArray(
key = key,
) {
list.forEach { item ->
val el = json.encodeToJsonElement(item)
add(el)
}
}
} else {
put(key, JsonNull)
}
}
when {
login != null -> {
val urisEntity = uris
.map { uri ->
val match = uri.match?.let(UriMatchTypeEntity::of)
ItemLoginUriExportEntity(
uri = uri.uri,
match = match,
)
}
val credentialsEntity = login.fido2Credentials
.map { credential ->
ItemLoginFido2CredentialsExportEntity(
credentialId = credential.credentialId,
keyType = credential.keyType,
keyAlgorithm = credential.keyAlgorithm,
keyCurve = credential.keyCurve,
keyValue = credential.keyValue,
rpId = credential.rpId,
rpName = credential.rpName,
counter = credential.counter?.toString()
?: "0",
userHandle = credential.userHandle,
userName = credential.userName,
userDisplayName = credential.userDisplayName,
discoverable = credential.discoverable.toString(),
creationDate = credential.creationDate,
)
}
val entity = ItemLoginExportEntity(
uris = urisEntity,
username = login.username,
password = login.password,
passwordRevisionDate = login.passwordRevisionDate,
totp = login.totp?.raw,
fido2Credentials = credentialsEntity,
)
val value = json.encodeToJsonElement(entity)
put("login", value)
}
card != null -> {
val entity = ItemCardExportEntity(
cardholderName = card.cardholderName,
brand = card.brand,
number = card.number,
expMonth = card.expMonth,
expYear = card.expYear,
code = card.code,
)
val value = json.encodeToJsonElement(entity)
put("card", value)
}
identity != null -> {
val entity = ItemIdentityExportEntity(
title = identity.title,
firstName = identity.firstName,
middleName = identity.middleName,
lastName = identity.lastName,
address1 = identity.address1,
address2 = identity.address2,
address3 = identity.address3,
city = identity.city,
state = identity.state,
postalCode = identity.postalCode,
country = identity.country,
company = identity.company,
email = identity.email,
phone = identity.phone,
ssn = identity.ssn,
username = identity.username,
passportNumber = identity.passportNumber,
licenseNumber = identity.licenseNumber,
)
val value = json.encodeToJsonElement(entity)
put("identity", value)
}
// secure note
type == DSecret.Type.SecureNote -> {
val value = buildJsonObject {
put("type", 0)
}
put("secureNote", value)
}
}
}
}

View File

@ -2,4 +2,5 @@ package com.artemchep.keyguard.common.service.permission
expect enum class Permission {
POST_NOTIFICATIONS,
WRITE_EXTERNAL_STORAGE,
}

View File

@ -0,0 +1,9 @@
package com.artemchep.keyguard.common.service.zip
data class ZipConfig(
val encryption: Encryption? = null,
) {
data class Encryption(
val password: String,
)
}

View File

@ -0,0 +1,9 @@
package com.artemchep.keyguard.common.service.zip
import java.io.InputStream
class ZipEntry(
val name: String,
val stream: () -> InputStream,
) {
}

View File

@ -0,0 +1,11 @@
package com.artemchep.keyguard.common.service.zip
import java.io.OutputStream
interface ZipService {
fun zip(
outputStream: OutputStream,
config: ZipConfig,
entries: List<ZipEntry>,
)
}

View File

@ -3,6 +3,10 @@ package com.artemchep.keyguard.common.usecase
import kotlinx.datetime.Instant
interface DateFormatter {
fun formatDateTimeMachine(
instant: Instant,
): String
fun formatDateTime(
instant: Instant,
): String

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.DFilter
interface ExportAccount : (
DFilter,
String,
) -> IO<Unit>

View File

@ -56,6 +56,7 @@ import com.artemchep.keyguard.common.usecase.CipherUnsecureUrlCheck
import com.artemchep.keyguard.common.usecase.CopyCipherById
import com.artemchep.keyguard.common.usecase.DownloadAttachment
import com.artemchep.keyguard.common.usecase.EditWordlist
import com.artemchep.keyguard.common.usecase.ExportAccount
import com.artemchep.keyguard.common.usecase.FavouriteCipherById
import com.artemchep.keyguard.common.usecase.GetAccountHasError
import com.artemchep.keyguard.common.usecase.GetAccountStatus
@ -162,6 +163,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.CipherRemovePasswordHis
import com.artemchep.keyguard.provider.bitwarden.usecase.CipherUnsecureUrlAutoFixImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.CipherUnsecureUrlCheckImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.CopyCipherByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.ExportAccountImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.FavouriteCipherByIdImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.GetAccountHasErrorImpl
import com.artemchep.keyguard.provider.bitwarden.usecase.GetAccountsHasErrorImpl
@ -410,6 +412,9 @@ fun DI.Builder.createSubDi2(
bindSingleton<AddFolder> {
AddFolderImpl(this)
}
bindSingleton<ExportAccount> {
ExportAccountImpl(this)
}
bindSingleton<PutAccountColorById> {
PutAccountColorByIdImpl(this)
}

View File

@ -11,8 +11,6 @@ import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Fingerprint
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material.icons.outlined.HideSource
import androidx.compose.material.icons.outlined.Keyboard
import androidx.compose.material.icons.outlined.Login
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.Security
@ -72,6 +70,7 @@ import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
import com.artemchep.keyguard.feature.crashlytics.crashlyticsTap
import com.artemchep.keyguard.feature.emailleak.EmailLeakRoute
import com.artemchep.keyguard.feature.export.ExportRoute
import com.artemchep.keyguard.feature.home.vault.VaultRoute
import com.artemchep.keyguard.feature.home.vault.collections.CollectionsRoute
import com.artemchep.keyguard.feature.home.vault.folders.FoldersRoute
@ -373,6 +372,18 @@ fun accountState(
::doSyncAccountById.partially1(accountOrNull.id)
},
)
this += ExportRoute.actionOrNull(
translator = this@produceScreenState,
accountId = accountId,
individual = true,
navigate = ::navigate,
)
this += ExportRoute.actionOrNull(
translator = this@produceScreenState,
accountId = accountId,
individual = false,
navigate = ::navigate,
)
}
section {
if (profileOrNull == null) {

View File

@ -2,12 +2,13 @@ package com.artemchep.keyguard.feature.auth.common
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import arrow.optics.optics
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
@Stable
@optics
data class TextFieldModel2(
val state: MutableState<String>,

View File

@ -17,9 +17,12 @@ enum class ValidationPassword {
}
}
fun validatePassword(password: String?): ValidationPassword {
fun validatePassword(
password: String?,
minLength: Int = ValidationPassword.Const.MIN_LENGTH,
): ValidationPassword {
val passwordLength = password?.trim()?.length ?: 0
return if (passwordLength < ValidationPassword.Const.MIN_LENGTH) {
return if (passwordLength < minLength) {
ValidationPassword.ERROR_MIN_LENGTH
} else {
ValidationPassword.OK
@ -36,9 +39,15 @@ fun ValidationPassword.format(scope: TranslatorScope): String? =
else -> null
}
fun Flow<String>.validatedPassword(scope: TranslatorScope) = this
fun Flow<String>.validatedPassword(
scope: TranslatorScope,
minLength: Int = ValidationPassword.Const.MIN_LENGTH,
) = this
.map { password ->
val passwordError = validatePassword(password)
val passwordError = validatePassword(
password = password,
minLength = minLength,
)
.format(scope)
if (passwordError != null) {
Validated.Failure(

View File

@ -45,6 +45,7 @@ import com.artemchep.keyguard.feature.home.vault.util.cipherDeleteAction
import com.artemchep.keyguard.feature.home.vault.util.cipherDisableConfirmAccessAction
import com.artemchep.keyguard.feature.home.vault.util.cipherEditAction
import com.artemchep.keyguard.feature.home.vault.util.cipherEnableConfirmAccessAction
import com.artemchep.keyguard.feature.home.vault.util.cipherExportAction
import com.artemchep.keyguard.feature.home.vault.util.cipherMergeInto
import com.artemchep.keyguard.feature.home.vault.util.cipherMergeIntoAction
import com.artemchep.keyguard.feature.home.vault.util.cipherMoveToFolderAction
@ -557,6 +558,10 @@ fun RememberStateFlowScope.createCipherSelectionFlow(
ciphers = selectedCiphers,
)
actions += cipherExportAction(
ciphers = selectedCiphers,
)
if (canDelete && selectedCiphers.any { it.deletedDate != null }) {
actions += cipherRestoreAction(
restoreCipherById = toolbox.restoreCipherById,

View File

@ -0,0 +1,129 @@
package com.artemchep.keyguard.feature.export
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SaveAlt
import androidx.compose.runtime.Composable
import com.artemchep.keyguard.common.model.AccountId
import com.artemchep.keyguard.common.model.DFilter
import com.artemchep.keyguard.feature.confirmation.elevatedaccess.ElevatedAccessResult
import com.artemchep.keyguard.feature.confirmation.elevatedaccess.ElevatedAccessRoute
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.Route
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
import com.artemchep.keyguard.feature.navigation.state.TranslatorScope
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.icons.Stub
import com.artemchep.keyguard.ui.icons.icon
data class ExportRoute(
val args: Args,
) : Route {
companion object {
// TODO: Change it to true after the app stops asking
// biometrics twice.
private const val REQUIRE_ELEVATED_ACCESS = false
fun actionOrNull(
translator: TranslatorScope,
accountId: AccountId,
individual: Boolean,
navigate: (NavigationIntent) -> Unit,
) = action(
translator = translator,
accountId = accountId,
individual = individual,
navigate = navigate,
)
fun action(
translator: TranslatorScope,
accountId: AccountId,
individual: Boolean,
navigate: (NavigationIntent) -> Unit,
): FlatItemAction {
val title = kotlin.run {
val res = if (individual) {
Res.strings.account_action_export_individual_vault_title
} else {
Res.strings.account_action_export_vault_title
}
translator.translate(res)
}
return FlatItemAction(
leading = kotlin.run {
val res = if (individual) {
Icons.Outlined.SaveAlt
} else {
Icons.Stub
}
icon(res)
},
title = title,
onClick = {
val accountFilter = DFilter.ById(
id = accountId.id,
what = DFilter.ById.What.ACCOUNT,
)
val filter = if (individual) {
val orgFilter = DFilter.ById(
id = null,
what = DFilter.ById.What.ORGANIZATION,
)
DFilter.And(
filters = listOf(
accountFilter,
orgFilter,
),
)
} else {
accountFilter
}
val route = ExportRoute(
args = Args(
title = title,
filter = filter,
),
)
val intent = NavigationIntent.NavigateToRoute(route)
navigate(
intent = intent,
navigate = navigate,
)
},
)
}
fun navigate(
intent: NavigationIntent,
navigate: (NavigationIntent) -> Unit,
) {
if (!REQUIRE_ELEVATED_ACCESS) {
navigate(intent)
return
}
val elevatedRoute = registerRouteResultReceiver(
route = ElevatedAccessRoute(),
) { result ->
if (result is ElevatedAccessResult.Allow) {
navigate(intent)
}
}
val elevatedIntent = NavigationIntent.NavigateToRoute(elevatedRoute)
navigate(elevatedIntent)
}
}
data class Args(
val title: String? = null,
val filter: DFilter? = null,
)
@Composable
override fun Content() {
ExportScreen(
args = args,
)
}
}

View File

@ -0,0 +1,448 @@
package com.artemchep.keyguard.feature.export
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
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.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.SaveAlt
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.service.permission.PermissionState
import com.artemchep.keyguard.feature.home.vault.model.FilterItem
import com.artemchep.keyguard.feature.navigation.NavigationIcon
import com.artemchep.keyguard.feature.search.filter.FilterButton
import com.artemchep.keyguard.feature.search.filter.FilterScreen
import com.artemchep.keyguard.feature.twopane.TwoPaneScreen
import com.artemchep.keyguard.platform.LocalLeContext
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.AutofillButton
import com.artemchep.keyguard.ui.DefaultFab
import com.artemchep.keyguard.ui.ExpandedIfNotEmpty
import com.artemchep.keyguard.ui.FabState
import com.artemchep.keyguard.ui.FlatItem
import com.artemchep.keyguard.ui.MediumEmphasisAlpha
import com.artemchep.keyguard.ui.OptionsButton
import com.artemchep.keyguard.ui.PasswordFlatTextField
import com.artemchep.keyguard.ui.ScaffoldColumn
import com.artemchep.keyguard.ui.icons.ChevronIcon
import com.artemchep.keyguard.ui.icons.KeyguardCipher
import com.artemchep.keyguard.ui.icons.icon
import com.artemchep.keyguard.ui.skeleton.SkeletonTextField
import com.artemchep.keyguard.ui.theme.Dimens
import com.artemchep.keyguard.ui.theme.badgeContainer
import com.artemchep.keyguard.ui.theme.combineAlpha
import com.artemchep.keyguard.ui.theme.onWarningContainer
import com.artemchep.keyguard.ui.theme.warning
import com.artemchep.keyguard.ui.theme.warningContainer
import com.artemchep.keyguard.ui.toolbar.LargeToolbar
import com.artemchep.keyguard.ui.toolbar.SmallToolbar
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.collections.immutable.persistentListOf
@Composable
fun ExportScreen(
args: ExportRoute.Args,
) {
val loadableState = produceExportScreenState(
args = args,
)
val title = args.title
?: stringResource(Res.strings.exportaccount_header_title)
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
when (loadableState) {
is Loadable.Ok -> {
val state = loadableState.value
ExportScreenOk(
title = title,
scrollBehavior = scrollBehavior,
state = state,
)
}
is Loadable.Loading -> {
ExportScreenSkeleton(
title = title,
scrollBehavior = scrollBehavior,
)
}
}
}
@Composable
fun ExportScreenSkeleton(
title: String,
scrollBehavior: TopAppBarScrollBehavior,
) {
TwoPaneScreen(
header = { modifier ->
SmallToolbar(
modifier = modifier,
containerColor = Color.Transparent,
title = {
Text(
text = title,
)
},
navigationIcon = {
NavigationIcon()
},
)
SideEffect {
if (scrollBehavior.state.heightOffsetLimit != 0f) {
scrollBehavior.state.heightOffsetLimit = 0f
}
}
},
detail = { modifier ->
val items = persistentListOf<FilterItem>()
ExportScreenFilterList(
modifier = modifier,
items = items,
onClear = null,
)
},
) { modifier, tabletUi ->
ExportScreen(
modifier = modifier,
items = null,
filter = null,
password = null,
content = null,
loading = true,
tabletUi = tabletUi,
title = title,
scrollBehavior = scrollBehavior,
)
}
}
@Composable
fun ExportScreenOk(
title: String,
scrollBehavior: TopAppBarScrollBehavior,
state: ExportState,
) {
val items by state.itemsFlow.collectAsState()
val filter by state.filterFlow.collectAsState()
val password by state.passwordFlow.collectAsState()
val content by state.contentFlow.collectAsState()
TwoPaneScreen(
header = { modifier ->
SmallToolbar(
modifier = modifier,
containerColor = Color.Transparent,
title = {
Text(
text = title,
)
},
navigationIcon = {
NavigationIcon()
},
actions = {
OptionsButton(
//actions = state.actions,
)
},
)
SideEffect {
if (scrollBehavior.state.heightOffsetLimit != 0f) {
scrollBehavior.state.heightOffsetLimit = 0f
}
}
},
detail = { modifier ->
ExportScreenFilterList(
modifier = modifier,
items = filter.items,
onClear = filter.onClear,
)
},
) { modifier, tabletUi ->
ExportScreen(
modifier = modifier,
items = items,
filter = filter,
password = password,
content = content,
loading = false,
tabletUi = tabletUi,
title = title,
scrollBehavior = scrollBehavior,
)
}
}
//
// Filter
//
@Composable
private fun ExportScreenFilterList(
modifier: Modifier = Modifier,
items: List<FilterItem>,
onClear: (() -> Unit)?,
) {
FilterScreen(
modifier = modifier,
count = null,
items = items,
onClear = onClear,
actions = {
},
)
}
@Composable
private fun ExportScreenFilterButton(
modifier: Modifier = Modifier,
items: List<FilterItem>,
onClear: (() -> Unit)?,
) {
FilterButton(
modifier = modifier,
count = null,
items = items,
onClear = onClear,
)
}
//
// Main
//
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ExportScreen(
modifier: Modifier,
items: ExportState.Items? = null,
filter: ExportState.Filter? = null,
password: ExportState.Password? = null,
content: ExportState.Content? = null,
loading: Boolean,
tabletUi: Boolean,
title: String,
scrollBehavior: TopAppBarScrollBehavior,
) {
ScaffoldColumn(
modifier = modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
topAppBarScrollBehavior = scrollBehavior,
topBar = {
if (tabletUi) {
return@ScaffoldColumn
}
LargeToolbar(
title = {
Text(
text = title,
)
},
navigationIcon = {
NavigationIcon()
},
actions = {
if (filter != null) {
ExportScreenFilterButton(
modifier = Modifier,
items = filter.items,
onClear = filter.onClear,
)
}
},
scrollBehavior = scrollBehavior,
)
},
floatingActionState = run {
val fabOnClick = content?.onExportClick
val fabState = if (fabOnClick != null) {
FabState(
onClick = fabOnClick,
model = null,
)
} else {
null
}
rememberUpdatedState(newValue = fabState)
},
floatingActionButton = {
DefaultFab(
icon = {
Icon(Icons.Outlined.SaveAlt, null)
},
text = {
Text(
text = stringResource(Res.strings.exportaccount_export_button),
)
},
)
},
) {
// Add extra padding to match the horizontal and
// vertical paddings.
if (tabletUi) {
Spacer(
modifier = Modifier
.height(8.dp),
)
}
if (loading) {
ExportContentSkeleton()
} else if (
items != null &&
password != null &&
content != null
) {
ExportContentOk(
items = items,
password = password,
content = content,
)
}
Spacer(
modifier = Modifier
.height(32.dp),
)
Icon(
modifier = Modifier
.padding(horizontal = Dimens.horizontalPadding),
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = LocalContentColor.current.combineAlpha(alpha = MediumEmphasisAlpha),
)
Spacer(
modifier = Modifier
.height(16.dp),
)
Text(
modifier = Modifier
.padding(horizontal = Dimens.horizontalPadding),
text = stringResource(Res.strings.exportaccount_no_attachments_note),
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current
.combineAlpha(alpha = MediumEmphasisAlpha),
)
}
}
@Composable
private fun ColumnScope.ExportContentSkeleton(
) {
SkeletonTextField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Dimens.horizontalPadding),
)
}
@Composable
private fun ColumnScope.ExportContentOk(
items: ExportState.Items,
password: ExportState.Password,
content: ExportState.Content,
) {
ExpandedIfNotEmpty(
valueOrNull = content.writePermission as? PermissionState.Declined,
) { permission ->
val updatedContext by rememberUpdatedState(LocalLeContext)
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onWarningContainer,
) {
FlatItem(
modifier = Modifier
.padding(bottom = 8.dp),
backgroundColor = MaterialTheme.colorScheme.warningContainer,
paddingValues = PaddingValues(
horizontal = 16.dp,
vertical = 0.dp,
),
leading = icon<RowScope>(Icons.Outlined.Storage, Icons.Outlined.Warning),
title = {
Text(
text = stringResource(Res.strings.pref_item_permission_write_external_storage_grant),
)
},
onClick = {
permission.ask(updatedContext)
},
)
}
}
PasswordFlatTextField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Dimens.horizontalPadding),
label = stringResource(Res.strings.exportaccount_password_label),
value = password.model,
trailing = {
AutofillButton(
key = "password",
password = true,
onValueChange = {
password.model.onChange?.invoke(it)
},
)
},
)
Spacer(
modifier = Modifier
.height(16.dp),
)
FlatItem(
leading = {
BadgedBox(
badge = {
Badge(
containerColor = MaterialTheme.colorScheme.badgeContainer,
) {
val size = items.count
Text(text = size.toString())
}
},
) {
Icon(Icons.Outlined.KeyguardCipher, null)
}
},
title = {
Text(
text = stringResource(Res.strings.items),
)
},
trailing = {
ChevronIcon()
},
onClick = items.onView,
)
}

View File

@ -0,0 +1,38 @@
package com.artemchep.keyguard.feature.export
import androidx.compose.runtime.Immutable
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.common.service.permission.PermissionState
import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
import com.artemchep.keyguard.feature.home.vault.model.FilterItem
import kotlinx.coroutines.flow.StateFlow
data class ExportState(
val itemsFlow: StateFlow<Items>,
val filterFlow: StateFlow<Filter>,
val passwordFlow: StateFlow<Password>,
val contentFlow: StateFlow<Content>,
) {
data class Content(
val writePermission: PermissionState,
val onExportClick: (() -> Unit)? = null,
)
data class Password(
val model: TextFieldModel2,
)
@Immutable
data class Filter(
val items: List<FilterItem>,
val onClear: (() -> Unit)? = null,
)
@Immutable
data class Items(
val revision: Int,
val list: List<DSecret>,
val count: Int,
val onView: (() -> Unit)? = null,
)
}

View File

@ -0,0 +1,281 @@
package com.artemchep.keyguard.feature.export
import androidx.compose.runtime.Composable
import arrow.core.identity
import arrow.core.partially1
import com.artemchep.keyguard.common.io.effectTap
import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.model.DFilter
import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.model.ToastMessage
import com.artemchep.keyguard.common.service.permission.Permission
import com.artemchep.keyguard.common.service.permission.PermissionService
import com.artemchep.keyguard.common.service.permission.PermissionState
import com.artemchep.keyguard.common.usecase.ExportAccount
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.GetOrganizations
import com.artemchep.keyguard.common.usecase.GetProfiles
import com.artemchep.keyguard.common.usecase.filterHiddenProfiles
import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
import com.artemchep.keyguard.feature.auth.common.Validated
import com.artemchep.keyguard.feature.auth.common.util.validatedPassword
import com.artemchep.keyguard.feature.home.vault.VaultRoute
import com.artemchep.keyguard.feature.home.vault.screen.FilterParams
import com.artemchep.keyguard.feature.home.vault.screen.ah
import com.artemchep.keyguard.feature.home.vault.screen.createFilter
import com.artemchep.keyguard.feature.home.vault.search.filter.FilterHolder
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.state.navigatePopSelf
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.res.Res
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import org.kodein.di.DirectDI
import org.kodein.di.compose.localDI
import org.kodein.di.direct
import org.kodein.di.instance
@Composable
fun produceExportScreenState(
args: ExportRoute.Args,
) = with(localDI().direct) {
produceExportScreenState(
directDI = this,
args = args,
getAccounts = instance(),
getProfiles = instance(),
getCiphers = instance(),
getFolders = instance(),
getCollections = instance(),
getOrganizations = instance(),
permissionService = instance(),
exportAccount = instance(),
)
}
private data class FilteredBoo<T>(
val list: List<T>,
val filterConfig: FilterHolder? = null,
)
@Composable
fun produceExportScreenState(
directDI: DirectDI,
args: ExportRoute.Args,
getAccounts: GetAccounts,
getProfiles: GetProfiles,
getCiphers: GetCiphers,
getFolders: GetFolders,
getCollections: GetCollections,
getOrganizations: GetOrganizations,
permissionService: PermissionService,
exportAccount: ExportAccount,
): Loadable<ExportState> = produceScreenState(
key = "export",
initial = Loadable.Loading,
) {
val passwordSink = mutablePersistedFlow(
key = "password",
) { "" }
val passwordState = mutableComposeState(passwordSink)
fun onExport(
password: String,
filter: DFilter,
) {
exportAccount(
filter,
password,
)
.effectTap {
val msg = ToastMessage(
title = translate(Res.strings.exportaccount_export_success),
type = ToastMessage.Type.SUCCESS,
)
message(msg)
// Close the screen
navigatePopSelf()
}
.launchIn(appScope)
}
val writeDownloadsPermissionFlow = permissionService
.getState(Permission.WRITE_EXTERNAL_STORAGE)
val ciphersRawFlow = filterHiddenProfiles(
getProfiles = getProfiles,
getCiphers = getCiphers,
filter = args.filter,
)
.map { ciphers ->
if (args.filter != null) {
val predicate = args.filter.prepare(directDI, ciphers)
ciphers
.filter { predicate(it) }
} else {
ciphers
}
}
val ciphersFlow = ciphersRawFlow
.map { secrets ->
secrets
.filter { secret -> !secret.deleted }
}
.shareIn(screenScope, SharingStarted.WhileSubscribed(), replay = 1)
val filterResult = createFilter()
val filteredCiphersFlow = ciphersFlow
.map {
FilteredBoo(
list = it,
)
}
.combine(
flow = filterResult.filterFlow,
) { state, filterConfig ->
// Fast path: if the there are no filters, then
// just return original list of items.
if (filterConfig.state.isEmpty()) {
return@combine state.copy(
filterConfig = filterConfig,
)
}
val filteredItems = kotlin.run {
val allItems = state.list
val predicate = filterConfig.filter.prepare(directDI, allItems)
allItems.filter(predicate)
}
state.copy(
list = filteredItems,
filterConfig = filterConfig,
)
}
.shareIn(screenScope, SharingStarted.WhileSubscribed(), replay = 1)
val filterRawFlow = ah(
directDI = directDI,
outputGetter = ::identity,
outputFlow = filteredCiphersFlow
.map { state ->
state.list
},
accountGetter = ::identity,
accountFlow = getAccounts(),
profileFlow = getProfiles(),
cipherGetter = ::identity,
cipherFlow = ciphersFlow,
folderGetter = ::identity,
folderFlow = getFolders(),
collectionGetter = ::identity,
collectionFlow = getCollections(),
organizationGetter = ::identity,
organizationFlow = getOrganizations(),
input = filterResult,
params = FilterParams(
section = FilterParams.Section(
custom = false,
),
),
)
val filterFlow = filterRawFlow
.map { filterState ->
ExportState.Filter(
items = filterState.items,
onClear = filterState.onClear,
)
}
.stateIn(screenScope)
val itemsFlow = filteredCiphersFlow
.map { state ->
ExportState.Items(
revision = state.filterConfig?.id ?: 0,
list = state.list,
count = state.list.size,
onView = {
val filter = state.filterConfig?.filter
val route = VaultRoute(
args = VaultRoute.Args(
appBar = VaultRoute.Args.AppBar(
title = translate(Res.strings.exportaccount_header_title),
),
filter = filter,
trash = false,
preselect = false,
canAddSecrets = false,
),
)
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
)
}
.stateIn(screenScope)
val passwordRawFlow = passwordSink
.validatedPassword(
scope = this,
minLength = 1,
)
.stateIn(screenScope)
val passwordFlow = passwordRawFlow
.map { passwordValidated ->
val model = TextFieldModel2.of(
passwordState,
passwordValidated,
)
ExportState.Password(
model = model,
)
}
.stateIn(screenScope)
val contentFlow = combine(
writeDownloadsPermissionFlow,
passwordRawFlow,
filterResult
.filterFlow,
) { writeDownloadsPermission, passwordValidated, filterHolder ->
val export = kotlin.run {
val canExport = passwordValidated is Validated.Success &&
writeDownloadsPermission is PermissionState.Granted
if (canExport) {
val filter = if (args.filter != null) {
DFilter.And(
filters = listOf(
args.filter,
filterHolder.filter,
),
)
} else {
filterHolder.filter
}
::onExport
.partially1(passwordValidated.model)
.partially1(filter)
} else {
null
}
}
ExportState.Content(
writePermission = writeDownloadsPermission,
onExportClick = export,
)
}
.stateIn(screenScope)
val state = ExportState(
itemsFlow = itemsFlow,
filterFlow = filterFlow,
passwordFlow = passwordFlow,
contentFlow = contentFlow,
)
flowOf(Loadable.Ok(state))
}

View File

@ -69,6 +69,7 @@ import com.artemchep.keyguard.feature.home.settings.component.settingPermissionC
import com.artemchep.keyguard.feature.home.settings.component.settingPermissionDetailsProvider
import com.artemchep.keyguard.feature.home.settings.component.settingPermissionOtherProvider
import com.artemchep.keyguard.feature.home.settings.component.settingPermissionPostNotificationsProvider
import com.artemchep.keyguard.feature.home.settings.component.settingPermissionWriteExternalStorageProvider
import com.artemchep.keyguard.feature.home.settings.component.settingPrivacyPolicyProvider
import com.artemchep.keyguard.feature.home.settings.component.settingRateAppProvider
import com.artemchep.keyguard.feature.home.settings.component.settingRequireMasterPasswordProvider
@ -126,6 +127,7 @@ object Setting {
const val PERMISSION_DETAILS = "permission_details" // screen
const val PERMISSION_OTHER = "permission_other"
const val PERMISSION_CAMERA = "permission_camera"
const val PERMISSION_WRITE_EXTERNAL_STORAGE = "permission_write_external_storage"
const val PERMISSION_POST_NOTIFICATION = "permission_post_notification"
const val BIOMETRIC = "biometric"
const val VAULT_PERSIST = "vault_persist"
@ -207,6 +209,7 @@ val hub = mapOf<String, (DirectDI) -> SettingComponent>(
Setting.PERMISSION_OTHER to ::settingPermissionOtherProvider,
Setting.PERMISSION_CAMERA to ::settingPermissionCameraProvider,
Setting.PERMISSION_POST_NOTIFICATION to ::settingPermissionPostNotificationsProvider,
Setting.PERMISSION_WRITE_EXTERNAL_STORAGE to ::settingPermissionWriteExternalStorageProvider,
Setting.BIOMETRIC to ::settingBiometricsProvider,
Setting.VAULT_PERSIST to ::settingVaultPersistProvider,
Setting.VAULT_LOCK to ::settingVaultLockProvider,

View File

@ -0,0 +1,7 @@
package com.artemchep.keyguard.feature.home.settings.component
import org.kodein.di.DirectDI
expect fun settingPermissionWriteExternalStorageProvider(
directDI: DirectDI,
): SettingComponent

View File

@ -17,6 +17,7 @@ fun PermissionsSettingsScreen() {
list = listOf(
SettingPaneItem.Item(Setting.PERMISSION_CAMERA),
SettingPaneItem.Item(Setting.PERMISSION_POST_NOTIFICATION),
SettingPaneItem.Item(Setting.PERMISSION_WRITE_EXTERNAL_STORAGE),
),
),
SettingPaneItem.Group(

View File

@ -129,6 +129,7 @@ data class FilterParams(
val collection: Boolean = true,
val folder: Boolean = true,
val misc: Boolean = true,
val custom: Boolean = true,
)
}
@ -754,7 +755,7 @@ suspend fun <
sectionTitle = translate(Res.strings.custom),
collapse = false,
)
.filterSection(params.section.misc)
.filterSection(params.section.custom)
return combine(
filterCustomListFlow,

View File

@ -152,6 +152,7 @@ import com.artemchep.keyguard.feature.home.vault.util.cipherCopyToAction
import com.artemchep.keyguard.feature.home.vault.util.cipherDeleteAction
import com.artemchep.keyguard.feature.home.vault.util.cipherDisableConfirmAccessAction
import com.artemchep.keyguard.feature.home.vault.util.cipherEnableConfirmAccessAction
import com.artemchep.keyguard.feature.home.vault.util.cipherExportAction
import com.artemchep.keyguard.feature.home.vault.util.cipherMoveToFolderAction
import com.artemchep.keyguard.feature.home.vault.util.cipherRestoreAction
import com.artemchep.keyguard.feature.home.vault.util.cipherTrashAction
@ -695,6 +696,9 @@ fun vaultViewScreenState(
patchWatchtowerAlertCipher = patchWatchtowerAlertCipher,
ciphers = listOf(secretOrNull),
),
cipherExportAction(
ciphers = listOf(secretOrNull),
),
cipherTrashAction(
trashCipherById = trashCipherById,
ciphers = listOf(secretOrNull),

View File

@ -13,6 +13,7 @@ import androidx.compose.material.icons.outlined.LockOpen
import androidx.compose.material.icons.outlined.Merge
import androidx.compose.material.icons.outlined.Password
import androidx.compose.material.icons.outlined.RestoreFromTrash
import androidx.compose.material.icons.outlined.SaveAlt
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
@ -24,6 +25,7 @@ import com.artemchep.keyguard.common.io.effectMap
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.model.AccountId
import com.artemchep.keyguard.common.model.DFilter
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.common.model.DWatchtowerAlert
import com.artemchep.keyguard.common.model.FolderOwnership2
@ -34,6 +36,7 @@ import com.artemchep.keyguard.common.usecase.ChangeCipherNameById
import com.artemchep.keyguard.common.usecase.ChangeCipherPasswordById
import com.artemchep.keyguard.common.usecase.CipherMerge
import com.artemchep.keyguard.common.usecase.CopyCipherById
import com.artemchep.keyguard.common.usecase.ExportAccount
import com.artemchep.keyguard.common.usecase.MoveCipherToFolderById
import com.artemchep.keyguard.common.usecase.PatchWatchtowerAlertCipher
import com.artemchep.keyguard.common.usecase.RePromptCipherById
@ -43,11 +46,13 @@ import com.artemchep.keyguard.common.usecase.TrashCipherById
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.confirmation.elevatedaccess.createElevatedAccessDialogIntent
import com.artemchep.keyguard.feature.confirmation.folder.FolderConfirmationResult
import com.artemchep.keyguard.feature.confirmation.folder.FolderConfirmationRoute
import com.artemchep.keyguard.feature.confirmation.organization.FolderInfo
import com.artemchep.keyguard.feature.confirmation.organization.OrganizationConfirmationResult
import com.artemchep.keyguard.feature.confirmation.organization.OrganizationConfirmationRoute
import com.artemchep.keyguard.feature.export.ExportRoute
import com.artemchep.keyguard.feature.home.vault.add.AddRoute
import com.artemchep.keyguard.feature.home.vault.add.LeAddRoute
import com.artemchep.keyguard.feature.home.vault.screen.VaultViewPasswordHistoryRoute
@ -206,6 +211,41 @@ fun RememberStateFlowScope.cipherMergeInto(
navigate(intent)
}
fun RememberStateFlowScope.cipherExportAction(
ciphers: List<DSecret>,
before: (() -> Unit)? = null,
after: ((Boolean) -> Unit)? = null,
) = kotlin.run {
val iconImageVector = Icons.Outlined.SaveAlt
val title = translate(Res.strings.ciphers_action_export_title)
FlatItemAction(
icon = iconImageVector,
title = title,
onClick = {
before?.invoke()
val route = ExportRoute(
args = ExportRoute.Args(
filter = DFilter.Or(
filters = ciphers
.map { cipher ->
DFilter.ById(
id = cipher.id,
what = DFilter.ById.What.CIPHER,
)
},
),
),
)
val intent = NavigationIntent.NavigateToRoute(route)
ExportRoute.navigate(
intent = intent,
navigate = ::navigate,
)
},
)
}
fun RememberStateFlowScope.cipherCopyToAction(
copyCipherById: CopyCipherById,
ciphers: List<DSecret>,

View File

@ -30,7 +30,7 @@ fun FilterSectionComposable(
modifier = modifier,
paddingValues = PaddingValues(
horizontal = 0.dp,
vertical = 2.dp,
vertical = 0.dp,
),
trailing = {
val targetRotationX =

View File

@ -239,6 +239,7 @@ fun produceWatchtowerState(
section = FilterParams.Section(
misc = false,
type = false,
custom = false,
),
),
)

View File

@ -27,6 +27,7 @@ fun BitwardenCipher.Companion.encrypted(
folderId = folderId,
organizationId = entity.organizationId,
collectionIds = entity.collectionIds?.toSet().orEmpty(),
createdDate = entity.creationDate,
revisionDate = entity.revisionDate,
deletedDate = entity.deletedDate,
// service fields

View File

@ -34,6 +34,9 @@ data class CipherEntity(
@JsonNames("revisionDate")
@SerialName("RevisionDate")
val revisionDate: Instant,
@JsonNames("creationDate")
@SerialName("CreationDate")
val creationDate: Instant? = null,
@JsonNames("type")
@SerialName("Type")
val type: CipherTypeEntity = CipherTypeEntity.Login,

View File

@ -1,5 +1,6 @@
package com.artemchep.keyguard.provider.bitwarden.entity
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
import com.artemchep.keyguard.provider.bitwarden.entity.serializer.CommonEnumIntSerializer
import com.artemchep.keyguard.provider.bitwarden.entity.serializer.IntEnum
@ -30,6 +31,16 @@ fun CipherTypeEntity.Companion.of(
BitwardenCipher.Type.Identity -> CipherTypeEntity.Identity
}
fun CipherTypeEntity.Companion.of(
model: DSecret.Type,
) = when (model) {
DSecret.Type.Login -> CipherTypeEntity.Login
DSecret.Type.SecureNote -> CipherTypeEntity.SecureNote
DSecret.Type.Card -> CipherTypeEntity.Card
DSecret.Type.Identity -> CipherTypeEntity.Identity
DSecret.Type.None -> null
}
fun CipherTypeEntity.domain() = when (this) {
CipherTypeEntity.Login -> BitwardenCipher.Type.Login
CipherTypeEntity.SecureNote -> BitwardenCipher.Type.SecureNote

View File

@ -1,5 +1,6 @@
package com.artemchep.keyguard.provider.bitwarden.entity
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
import com.artemchep.keyguard.provider.bitwarden.entity.serializer.CommonEnumIntSerializer
import com.artemchep.keyguard.provider.bitwarden.entity.serializer.IntEnum
@ -29,6 +30,15 @@ fun FieldTypeEntity.Companion.of(
BitwardenCipher.Field.Type.Linked -> FieldTypeEntity.Linked
}
fun FieldTypeEntity.Companion.of(
model: DSecret.Field.Type,
) = when (model) {
DSecret.Field.Type.Text -> FieldTypeEntity.Text
DSecret.Field.Type.Hidden -> FieldTypeEntity.Hidden
DSecret.Field.Type.Boolean -> FieldTypeEntity.Boolean
DSecret.Field.Type.Linked -> FieldTypeEntity.Linked
}
fun FieldTypeEntity.domain() = when (this) {
FieldTypeEntity.Text -> BitwardenCipher.Field.Type.Text
FieldTypeEntity.Hidden -> BitwardenCipher.Field.Type.Hidden

View File

@ -1,5 +1,6 @@
package com.artemchep.keyguard.provider.bitwarden.entity
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
import com.artemchep.keyguard.provider.bitwarden.entity.serializer.CommonEnumIntSerializer
import com.artemchep.keyguard.provider.bitwarden.entity.serializer.IntEnum
@ -80,6 +81,38 @@ fun LinkedIdTypeEntity.Companion.of(
BitwardenCipher.Field.LinkedId.Identity_FullName -> LinkedIdTypeEntity.Identity_FullName
}
fun LinkedIdTypeEntity.Companion.of(
model: DSecret.Field.LinkedId,
) = when (model) {
DSecret.Field.LinkedId.Login_Username -> LinkedIdTypeEntity.Login_Username
DSecret.Field.LinkedId.Login_Password -> LinkedIdTypeEntity.Login_Password
DSecret.Field.LinkedId.Card_CardholderName -> LinkedIdTypeEntity.Card_CardholderName
DSecret.Field.LinkedId.Card_ExpMonth -> LinkedIdTypeEntity.Card_ExpMonth
DSecret.Field.LinkedId.Card_ExpYear -> LinkedIdTypeEntity.Card_ExpYear
DSecret.Field.LinkedId.Card_Code -> LinkedIdTypeEntity.Card_Code
DSecret.Field.LinkedId.Card_Brand -> LinkedIdTypeEntity.Card_Brand
DSecret.Field.LinkedId.Card_Number -> LinkedIdTypeEntity.Card_Number
DSecret.Field.LinkedId.Identity_Title -> LinkedIdTypeEntity.Identity_Title
DSecret.Field.LinkedId.Identity_MiddleName -> LinkedIdTypeEntity.Identity_MiddleName
DSecret.Field.LinkedId.Identity_Address1 -> LinkedIdTypeEntity.Identity_Address1
DSecret.Field.LinkedId.Identity_Address2 -> LinkedIdTypeEntity.Identity_Address2
DSecret.Field.LinkedId.Identity_Address3 -> LinkedIdTypeEntity.Identity_Address3
DSecret.Field.LinkedId.Identity_City -> LinkedIdTypeEntity.Identity_City
DSecret.Field.LinkedId.Identity_State -> LinkedIdTypeEntity.Identity_State
DSecret.Field.LinkedId.Identity_PostalCode -> LinkedIdTypeEntity.Identity_PostalCode
DSecret.Field.LinkedId.Identity_Country -> LinkedIdTypeEntity.Identity_Country
DSecret.Field.LinkedId.Identity_Company -> LinkedIdTypeEntity.Identity_Company
DSecret.Field.LinkedId.Identity_Email -> LinkedIdTypeEntity.Identity_Email
DSecret.Field.LinkedId.Identity_Phone -> LinkedIdTypeEntity.Identity_Phone
DSecret.Field.LinkedId.Identity_Ssn -> LinkedIdTypeEntity.Identity_Ssn
DSecret.Field.LinkedId.Identity_Username -> LinkedIdTypeEntity.Identity_Username
DSecret.Field.LinkedId.Identity_PassportNumber -> LinkedIdTypeEntity.Identity_PassportNumber
DSecret.Field.LinkedId.Identity_LicenseNumber -> LinkedIdTypeEntity.Identity_LicenseNumber
DSecret.Field.LinkedId.Identity_FirstName -> LinkedIdTypeEntity.Identity_FirstName
DSecret.Field.LinkedId.Identity_LastName -> LinkedIdTypeEntity.Identity_LastName
DSecret.Field.LinkedId.Identity_FullName -> LinkedIdTypeEntity.Identity_FullName
}
fun LinkedIdTypeEntity.domain() = when (this) {
LinkedIdTypeEntity.Login_Username -> BitwardenCipher.Field.LinkedId.Login_Username
LinkedIdTypeEntity.Login_Password -> BitwardenCipher.Field.LinkedId.Login_Password

View File

@ -1,5 +1,6 @@
package com.artemchep.keyguard.provider.bitwarden.entity
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
import com.artemchep.keyguard.provider.bitwarden.entity.serializer.CommonEnumIntSerializer
import com.artemchep.keyguard.provider.bitwarden.entity.serializer.IntEnum
@ -34,6 +35,17 @@ fun UriMatchTypeEntity.Companion.of(
BitwardenCipher.Login.Uri.MatchType.Never -> UriMatchTypeEntity.Never
}
fun UriMatchTypeEntity.Companion.of(
model: DSecret.Uri.MatchType,
) = when (model) {
DSecret.Uri.MatchType.Domain -> UriMatchTypeEntity.Domain
DSecret.Uri.MatchType.Host -> UriMatchTypeEntity.Host
DSecret.Uri.MatchType.StartsWith -> UriMatchTypeEntity.StartsWith
DSecret.Uri.MatchType.Exact -> UriMatchTypeEntity.Exact
DSecret.Uri.MatchType.RegularExpression -> UriMatchTypeEntity.RegularExpression
DSecret.Uri.MatchType.Never -> UriMatchTypeEntity.Never
}
fun UriMatchTypeEntity.domain() = when (this) {
UriMatchTypeEntity.Domain -> BitwardenCipher.Login.Uri.MatchType.Domain
UriMatchTypeEntity.Host -> BitwardenCipher.Login.Uri.MatchType.Host

View File

@ -6,6 +6,7 @@ import com.artemchep.keyguard.core.store.bitwarden.BitwardenCollection
fun BitwardenCollection.toDomain(): DCollection {
return DCollection(
id = collectionId,
externalId = externalId,
organizationId = organizationId,
accountId = accountId,
revisionDate = revisionDate,

View File

@ -0,0 +1,140 @@
package com.artemchep.keyguard.provider.bitwarden.usecase
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.model.DFilter
import com.artemchep.keyguard.common.service.dirs.DirsService
import com.artemchep.keyguard.common.service.export.ExportService
import com.artemchep.keyguard.common.service.zip.ZipConfig
import com.artemchep.keyguard.common.service.zip.ZipEntry
import com.artemchep.keyguard.common.service.zip.ZipService
import com.artemchep.keyguard.common.usecase.DateFormatter
import com.artemchep.keyguard.common.usecase.ExportAccount
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.GetOrganizations
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock
import org.kodein.di.DirectDI
import org.kodein.di.instance
/**
* @author Artem Chepurnyi
*/
class ExportAccountImpl(
private val directDI: DirectDI,
private val exportService: ExportService,
private val dirsService: DirsService,
private val zipService: ZipService,
private val dateFormatter: DateFormatter,
private val getOrganizations: GetOrganizations,
private val getCollections: GetCollections,
private val getFolders: GetFolders,
private val getCiphers: GetCiphers,
) : ExportAccount {
companion object {
private const val TAG = "ExportAccount.bitwarden"
}
constructor(directDI: DirectDI) : this(
directDI = directDI,
exportService = directDI.instance(),
dirsService = directDI.instance(),
zipService = directDI.instance(),
dateFormatter = directDI.instance(),
getOrganizations = directDI.instance(),
getCollections = directDI.instance(),
getFolders = directDI.instance(),
getCiphers = directDI.instance(),
)
override fun invoke(
filter: DFilter,
password: String,
): IO<Unit> = ioEffect {
val ciphers = getCiphersByFilter(filter)
val folders = kotlin.run {
val foldersLocalIds = ciphers
.asSequence()
.map { it.folderId }
.toSet()
getFolders()
.map { folders ->
folders
.filter { it.id in foldersLocalIds }
}
.first()
}
val collections = kotlin.run {
val collectionIds = ciphers
.asSequence()
.flatMap { it.collectionIds }
.toSet()
getCollections()
.map { collections ->
collections
.filter { it.id in collectionIds }
}
.first()
}
val organizations = kotlin.run {
val organizationIds = ciphers
.asSequence()
.map { it.organizationId }
.toSet()
getOrganizations()
.map { organizations ->
organizations
.filter { it.id in organizationIds }
}
.first()
}
// Map vault data to the JSON export
// in the target type.
val json = exportService.export(
organizations = organizations,
collections = collections,
folders = folders,
ciphers = ciphers,
)
// val zipParams =
val fileName = kotlin.run {
val now = Clock.System.now()
val dt = dateFormatter.formatDateTimeMachine(now)
"keyguard_export_$dt.zip"
}
dirsService.saveToDownloads(fileName) { os ->
zipService.zip(
outputStream = os,
config = ZipConfig(
encryption = ZipConfig.Encryption(
password = password,
),
),
entries = listOf(
ZipEntry(
name = "vault.json",
stream = {
json.byteInputStream()
},
),
),
)
}.bind()
}
private suspend fun getCiphersByFilter(filter: DFilter) = getCiphers()
.map { ciphers ->
val predicate = filter.prepare(directDI, ciphers)
ciphers
.filter(predicate)
}
.first()
}

View File

@ -201,6 +201,8 @@
<string name="account_action_change_master_password_hint_title">Change master password hint</string>
<string name="account_action_sign_out_title">Sign out</string>
<string name="account_action_sign_in_title">Sign in</string>
<string name="account_action_export_vault_title">Export vault</string>
<string name="account_action_export_individual_vault_title">Export individual vault</string>
<string name="account_action_hide_title">Hide items</string>
<string name="account_action_hide_text">Hide the items from the main screens of the app</string>
<string name="account_action_email_verify_instructions_title">Visit Web vault to verify your email address</string>
@ -230,6 +232,7 @@
<string name="ciphers_action_edit_title">Edit</string>
<string name="ciphers_action_merge_title">Merge into…</string>
<string name="ciphers_action_copy_title">Copy to…</string>
<string name="ciphers_action_export_title">Export…</string>
<string name="ciphers_action_change_folder_title">Move to folder</string>
<string name="ciphers_action_change_name_title">Change name</string>
<string name="ciphers_action_change_names_title">Change names</string>
@ -824,6 +827,12 @@
<string name="changepassword_disclaimer_local_note">App password never gets stored on the device nor sent over the network. It is used to generate a secret key that is used to encrypt the local data.</string>
<string name="changepassword_disclaimer_abuse_note">Unless you suspect unauthorized access or discover a malware on the device, there is no need to change the password if it is a strong, unique password.</string>
<string name="exportaccount_header_title">Export items</string>
<string name="exportaccount_password_label">Archive password</string>
<string name="exportaccount_no_attachments_note">Only vault item information will be exported and will not include associated attachments.</string>
<string name="exportaccount_export_button">Export</string>
<string name="exportaccount_export_success">Export complete</string>
<string name="contactus_header_title">Contact us</string>
<string name="contactus_message_label">Your message</string>
<string name="contactus_english_note">Use English to ensure that your feedback doesn\'t get misunderstood.</string>
@ -978,6 +987,9 @@
<string name="pref_item_premium_manage_subscription_on_play_store_title">Manage on Play Store</string>
<string name="pref_item_permission_post_notifications_title">Post notifications</string>
<string name="pref_item_permission_post_notifications_text">Used to post downloading notifications</string>
<string name="pref_item_permission_write_external_storage_title">Write external storage</string>
<string name="pref_item_permission_write_external_storage_text">Used to save vault exports to the downloads directory</string>
<string name="pref_item_permission_write_external_storage_grant">Grant write external storage permission</string>
<string name="pref_item_permission_camera_title">Camera</string>
<string name="pref_item_permission_camera_text">Used to scan QR codes</string>
<string name="pref_item_allow_two_panel_layout_in_landscape_title">Allow two panel layout in landscape mode</string>

View File

@ -2,4 +2,5 @@ package com.artemchep.keyguard.common.service.permission
actual enum class Permission {
POST_NOTIFICATIONS,
WRITE_EXTERNAL_STORAGE,
}

View File

@ -1,13 +1,17 @@
package com.artemchep.keyguard.copy
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.service.dirs.DirsService
import kotlinx.coroutines.Dispatchers
import net.harawata.appdirs.AppDirsFactory
import org.kodein.di.DirectDI
import java.io.File
import java.io.OutputStream
class DataDirectory(
) {
) : DirsService {
companion object {
private val APP_NAME = "keyguard"
private val APP_AUTHOR = "ArtemChepurnyi"
@ -34,4 +38,19 @@ class DataDirectory(
val appDirs = AppDirsFactory.getInstance()
appDirs.getUserDownloadsDir(APP_NAME, null, APP_AUTHOR)
}
override fun saveToDownloads(
fileName: String,
write: suspend (OutputStream) -> Unit,
): IO<Unit> = ioEffect {
val downloadsDir = downloads()
.bind()
.let(::File)
val file = downloadsDir.resolve(fileName)
file.outputStream()
.use {
write(it)
}
Unit
}
}

View File

@ -0,0 +1,8 @@
package com.artemchep.keyguard.feature.home.settings.component
import kotlinx.coroutines.flow.flowOf
import org.kodein.di.DirectDI
actual fun settingPermissionWriteExternalStorageProvider(
directDI: DirectDI,
): SettingComponent = flowOf(null)

View File

@ -10,6 +10,7 @@ import kotlinx.datetime.toLocalDateTime
import org.kodein.di.DirectDI
import org.kodein.di.instance
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
class DateFormatterAndroid(
@ -20,12 +21,21 @@ class DateFormatterAndroid(
private val formatterDate = DateFormat.getDateInstance(DateFormat.LONG)
private val machineDateTime = SimpleDateFormat("yyyyMMddHHmmss")
constructor(
directDI: DirectDI,
) : this(
context = directDI.instance(),
)
override fun formatDateTimeMachine(
instant: Instant,
): String {
val date = instant.toEpochMilliseconds().let(::Date)
return machineDateTime.format(date)
}
override fun formatDateTime(
instant: Instant,
): String {

View File

@ -0,0 +1,69 @@
package com.artemchep.keyguard.copy
import com.artemchep.keyguard.common.service.zip.ZipConfig
import com.artemchep.keyguard.common.service.zip.ZipEntry
import com.artemchep.keyguard.common.service.zip.ZipService
import net.lingala.zip4j.io.outputstream.ZipOutputStream
import net.lingala.zip4j.model.ZipParameters
import net.lingala.zip4j.model.enums.AesKeyStrength
import net.lingala.zip4j.model.enums.CompressionMethod
import net.lingala.zip4j.model.enums.EncryptionMethod
import org.kodein.di.DirectDI
import java.io.OutputStream
class ZipServiceJvm(
) : ZipService {
constructor(
directDI: DirectDI,
) : this()
override fun zip(
outputStream: OutputStream,
config: ZipConfig,
entries: List<ZipEntry>,
) {
createZipStream(config, outputStream).use { zipStream ->
entries.forEach { entry ->
val entryParams = createZipParameters(
config = config,
fileName = entry.name,
)
zipStream.putNextEntry(entryParams)
try {
val inputStream = entry.stream()
inputStream.use {
it.copyTo(zipStream)
}
} finally {
zipStream.closeEntry()
}
}
}
}
private fun createZipStream(
config: ZipConfig,
outputStream: OutputStream,
): ZipOutputStream {
return if (config.encryption != null) {
val password = config.encryption.password
.toCharArray()
ZipOutputStream(outputStream, password)
} else {
ZipOutputStream(outputStream)
}
}
private fun createZipParameters(
config: ZipConfig,
fileName: String,
): ZipParameters = ZipParameters().apply {
compressionMethod = CompressionMethod.DEFLATE
if (config.encryption != null) {
encryptionMethod = EncryptionMethod.AES
aesKeyStrength = AesKeyStrength.KEY_STRENGTH_256
isEncryptFiles = true
}
fileNameInZip = fileName
}
}

View File

@ -12,6 +12,8 @@ import com.artemchep.keyguard.common.service.download.DownloadService
import com.artemchep.keyguard.common.service.download.DownloadServiceImpl
import com.artemchep.keyguard.common.service.execute.ExecuteCommand
import com.artemchep.keyguard.common.service.execute.impl.ExecuteCommandImpl
import com.artemchep.keyguard.common.service.export.ExportService
import com.artemchep.keyguard.common.service.export.impl.ExportServiceImpl
import com.artemchep.keyguard.common.service.extract.impl.LinkInfoExtractorExecute
import com.artemchep.keyguard.common.service.extract.impl.LinkInfoPlatformExtractor
import com.artemchep.keyguard.common.service.gpmprivapps.PrivilegedAppsService
@ -67,6 +69,7 @@ import com.artemchep.keyguard.common.service.vault.impl.SessionMetadataRepositor
import com.artemchep.keyguard.common.service.vault.impl.SessionRepositoryImpl
import com.artemchep.keyguard.common.service.wordlist.WordlistService
import com.artemchep.keyguard.common.service.wordlist.impl.WordlistServiceImpl
import com.artemchep.keyguard.common.service.zip.ZipService
import com.artemchep.keyguard.common.usecase.AuthConfirmMasterKeyUseCase
import com.artemchep.keyguard.common.usecase.AuthGenerateMasterKeyUseCase
import com.artemchep.keyguard.common.usecase.BiometricKeyDecryptUseCase
@ -323,6 +326,7 @@ import com.artemchep.keyguard.copy.GetPasswordStrengthJvm
import com.artemchep.keyguard.copy.NumberFormatterJvm
import com.artemchep.keyguard.copy.PasswordGeneratorDiceware
import com.artemchep.keyguard.copy.SimilarityServiceJvm
import com.artemchep.keyguard.copy.ZipServiceJvm
import com.artemchep.keyguard.core.store.DatabaseDispatcher
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
import com.artemchep.keyguard.crypto.CipherEncryptorImpl
@ -1066,6 +1070,11 @@ fun globalModuleJvm() = DI.Module(
directDI = this,
)
}
bindSingleton<ExportService> {
ExportServiceImpl(
directDI = this,
)
}
bindSingleton<WordlistService> {
WordlistServiceImpl(
directDI = this,
@ -1112,6 +1121,11 @@ fun globalModuleJvm() = DI.Module(
bindSingleton<LinkInfoExtractorExecute> {
LinkInfoExtractorExecute()
}
bindSingleton<ZipService> {
ZipServiceJvm(
directDI = this,
)
}
bindSingleton<SimilarityService> {
SimilarityServiceJvm(
directDI = this,

View File

@ -134,6 +134,8 @@ versionsPlugin = "0.51.0"
windowStyler = "0.3.2"
# https://github.com/Yubico/yubikit-android
yubiKit = "2.4.0"
# https://github.com/srikanth-lingala/zip4j
zip4j = "2.11.5"
# https://github.com/nulab/zxcvbn4j
# We use it to calculate password strength.
zxcvbn4j = "1.8.2"
@ -234,6 +236,7 @@ mm2d-touchicon-http-okhttp = { module = "net.mm2d.touchicon:touchicon-http-okhtt
moko-resources = { module = "dev.icerock.moko:resources", version.ref = "moko" }
moko-resources-compose = { module = "dev.icerock.moko:resources-compose", version.ref = "moko" }
moko-resources-test = { module = "dev.icerock.moko:resources-test", version.ref = "moko" }
lingala-zip4j = { module = "net.lingala.zip4j:zip4j", version.ref = "zip4j" }
nulabinc-zxcvbn = { module = "com.nulab-inc:zxcvbn", version.ref = "zxcvbn4j" }
ricecode-string-similarity = { module = "net.ricecode:string-similarity", version.ref = "stringSimilarity" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }