feat: Export account
This commit is contained in:
parent
2ef74f5310
commit
63a2cfa8fa
@ -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)
|
||||
|
@ -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" />
|
||||
|
||||
|
@ -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),
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
},
|
||||
)
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
@ -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
|
||||
}
|
@ -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?,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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>,
|
||||
)
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -2,4 +2,5 @@ package com.artemchep.keyguard.common.service.permission
|
||||
|
||||
expect enum class Permission {
|
||||
POST_NOTIFICATIONS,
|
||||
WRITE_EXTERNAL_STORAGE,
|
||||
}
|
||||
|
@ -0,0 +1,9 @@
|
||||
package com.artemchep.keyguard.common.service.zip
|
||||
|
||||
data class ZipConfig(
|
||||
val encryption: Encryption? = null,
|
||||
) {
|
||||
data class Encryption(
|
||||
val password: String,
|
||||
)
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package com.artemchep.keyguard.common.service.zip
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
class ZipEntry(
|
||||
val name: String,
|
||||
val stream: () -> InputStream,
|
||||
) {
|
||||
}
|
@ -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>,
|
||||
)
|
||||
}
|
@ -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
|
||||
|
@ -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>
|
@ -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)
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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>,
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
@ -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))
|
||||
}
|
@ -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,
|
||||
|
@ -0,0 +1,7 @@
|
||||
package com.artemchep.keyguard.feature.home.settings.component
|
||||
|
||||
import org.kodein.di.DirectDI
|
||||
|
||||
expect fun settingPermissionWriteExternalStorageProvider(
|
||||
directDI: DirectDI,
|
||||
): SettingComponent
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
|
@ -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>,
|
||||
|
@ -30,7 +30,7 @@ fun FilterSectionComposable(
|
||||
modifier = modifier,
|
||||
paddingValues = PaddingValues(
|
||||
horizontal = 0.dp,
|
||||
vertical = 2.dp,
|
||||
vertical = 0.dp,
|
||||
),
|
||||
trailing = {
|
||||
val targetRotationX =
|
||||
|
@ -239,6 +239,7 @@ fun produceWatchtowerState(
|
||||
section = FilterParams.Section(
|
||||
misc = false,
|
||||
type = false,
|
||||
custom = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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()
|
||||
|
||||
}
|
@ -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>
|
||||
|
@ -2,4 +2,5 @@ package com.artemchep.keyguard.common.service.permission
|
||||
|
||||
actual enum class Permission {
|
||||
POST_NOTIFICATIONS,
|
||||
WRITE_EXTERNAL_STORAGE,
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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" }
|
||||
|
Loading…
x
Reference in New Issue
Block a user