feature: Initial Export with attachments

This commit is contained in:
Artem Chepurnoy 2024-08-04 10:12:23 +03:00
parent 64c0b37558
commit 1d55080796
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
45 changed files with 2108 additions and 331 deletions

View File

@ -26,6 +26,7 @@ import com.artemchep.keyguard.common.model.MasterSession
import com.artemchep.keyguard.common.service.vault.KeyReadWriteRepository
import com.artemchep.keyguard.common.model.PersistedSession
import com.artemchep.keyguard.common.service.filter.GetCipherFilters
import com.artemchep.keyguard.common.service.session.VaultSessionLocker
import com.artemchep.keyguard.common.worker.Wrker
import com.artemchep.keyguard.feature.favicon.Favicon
import com.artemchep.keyguard.feature.localization.textResource
@ -159,35 +160,9 @@ class Main : BaseApp(), DIAware {
}
// timeout
var timeoutJob: Job? = null
val getVaultLockAfterTimeout: GetVaultLockAfterTimeout by instance()
val vaultSessionLocker: VaultSessionLocker by instance()
ProcessLifecycleOwner.get().bindBlock {
timeoutJob?.cancel()
timeoutJob = null
try {
// suspend forever
suspendCancellableCoroutine<Unit> { }
} finally {
timeoutJob = getVaultLockAfterTimeout()
.toIO()
// Wait for the timeout duration.
.effectMap { duration ->
delay(duration)
duration
}
.effectMap {
// Clear the current session.
val context = LeContext(this)
val session = MasterSession.Empty(
reason = textResource(Res.string.lock_reason_inactivity, context),
)
putVaultSession(session)
}
.flatten()
.attempt()
.launchIn(GlobalScope)
}
vaultSessionLocker.keepAlive()
}
// screen lock

View File

@ -168,6 +168,14 @@
</intent-filter>
</receiver>
<receiver
android:name="com.artemchep.keyguard.android.downloader.receiver.VaultExportActionReceiver"
android:exported="false">
<intent-filter>
<action android:name="${applicationId}.ACTION_VAULT_EXPORT_CANCEL" />
</intent-filter>
</receiver>
<receiver
android:name="com.artemchep.keyguard.android.downloader.receiver.CopyActionReceiver"
android:exported="false">

View File

@ -9,4 +9,8 @@ object Notifications {
)
val uploads = NotificationIdPool.sequential(20000)
val totp = NotificationIdPool.sequential(30000)
val export = NotificationIdPool.sequential(
start = 40000,
endExclusive = 50000,
)
}

View File

@ -2,6 +2,7 @@ package com.artemchep.keyguard.android.downloader
import android.app.Application
import android.content.Context
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.crypto.FileEncryptor
import com.artemchep.keyguard.common.usecase.WindowCoroutineScope
import com.artemchep.keyguard.copy.download.DownloadClientJvm
@ -11,6 +12,7 @@ import org.kodein.di.instance
class DownloadClientAndroid(
private val context: Context,
cryptoGenerator: CryptoGenerator,
windowCoroutineScope: WindowCoroutineScope,
okHttpClient: OkHttpClient,
fileEncryptor: FileEncryptor,
@ -18,6 +20,7 @@ class DownloadClientAndroid(
cacheDirProvider = {
context.cacheDir
},
cryptoGenerator = cryptoGenerator,
windowCoroutineScope = windowCoroutineScope,
okHttpClient = okHttpClient,
fileEncryptor = fileEncryptor,
@ -26,6 +29,7 @@ class DownloadClientAndroid(
directDI: DirectDI,
) : this(
context = directDI.instance<Application>(),
cryptoGenerator = directDI.instance(),
windowCoroutineScope = directDI.instance(),
okHttpClient = directDI.instance(),
fileEncryptor = directDI.instance(),

View File

@ -0,0 +1,33 @@
package com.artemchep.keyguard.android.downloader
import android.app.Application
import android.content.Context
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.crypto.FileEncryptor
import com.artemchep.keyguard.copy.download.DownloadTaskJvm
import okhttp3.OkHttpClient
import org.kodein.di.DirectDI
import org.kodein.di.instance
class DownloadTaskAndroid(
private val context: Context,
cryptoGenerator: CryptoGenerator,
okHttpClient: OkHttpClient,
fileEncryptor: FileEncryptor,
) : DownloadTaskJvm(
cacheDirProvider = {
context.cacheDir
},
cryptoGenerator = cryptoGenerator,
okHttpClient = okHttpClient,
fileEncryptor = fileEncryptor,
) {
constructor(
directDI: DirectDI,
) : this(
context = directDI.instance<Application>(),
cryptoGenerator = directDI.instance(),
okHttpClient = directDI.instance(),
fileEncryptor = directDI.instance(),
)
}

View File

@ -0,0 +1,31 @@
package com.artemchep.keyguard.android.downloader
import android.app.Application
import android.content.Context
import com.artemchep.keyguard.android.downloader.worker.ExportWorker
import com.artemchep.keyguard.common.service.export.impl.ExportManagerBase
import org.kodein.di.DirectDI
import org.kodein.di.instance
class ExportManagerImpl(
private val directDI: DirectDI,
private val context: Context,
) : ExportManagerBase(
directDI = directDI,
onLaunch = { id ->
val args = ExportWorker.Args(
exportId = id,
)
ExportWorker.enqueueOnce(
context = context,
args = args,
)
}
) {
constructor(
directDI: DirectDI,
) : this(
directDI = directDI,
context = directDI.instance<Application>(),
)
}

View File

@ -0,0 +1,82 @@
package com.artemchep.keyguard.android.downloader.receiver
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.work.ListenableWorker.Result
import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.model.MasterSession
import com.artemchep.keyguard.common.model.RemoveAttachmentRequest
import com.artemchep.keyguard.common.service.export.ExportManager
import com.artemchep.keyguard.common.usecase.GetVaultSession
import com.artemchep.keyguard.common.usecase.RemoveAttachment
import com.artemchep.keyguard.common.usecase.WindowCoroutineScope
import org.kodein.di.android.closestDI
import org.kodein.di.direct
import org.kodein.di.instance
class VaultExportActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_VAULT_EXPORT_CANCEL = ".ACTION_VAULT_EXPORT_CANCEL"
const val KEY_EXPORT_ID = "export_id"
fun cancel(
context: Context,
exportId: String,
): Intent = intent(
context = context,
suffix = ACTION_VAULT_EXPORT_CANCEL,
) {
putExtra(KEY_EXPORT_ID, exportId)
}
fun intent(
context: Context,
suffix: String,
builder: Intent.() -> Unit = {},
): Intent {
val action = kotlin.run {
val packageName = context.packageName
"$packageName$suffix"
}
return Intent(action).apply {
component = ComponentName(context, VaultExportActionReceiver::class.java)
builder()
}
}
}
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
?: return
val di by closestDI { context }
when {
action.endsWith(ACTION_VAULT_EXPORT_CANCEL) -> {
val exportId = intent.extras?.getString(KEY_EXPORT_ID)
?: return
val windowCoroutineScope: WindowCoroutineScope by di.instance()
// Try to get the export manager from
// a current session.
val exportManager: ExportManager = kotlin.run {
val getSession: GetVaultSession = di.direct.instance()
val s = getSession.valueOrNull as? MasterSession.Key
?: return@run null
s.di.direct.instance()
} ?: return
// TODO:
val removeIo = kotlin.run {
val request = RemoveAttachmentRequest.ByDownloadId(
downloadId = exportId,
)
val removeAttachment: RemoveAttachment by di.instance()
removeAttachment(listOf(request))
}
removeIo.launchIn(windowCoroutineScope)
}
}
}
}

View File

@ -235,7 +235,7 @@ class AttachmentDownloadWorker(
total: String? = null,
): ForegroundInfo {
val notification = kotlin.run {
val channelId = createAttachmentDownloadChannel()
val channelId = createNotificationChannel()
// Progress
val progressMax = PROGRESS_MAX
@ -296,7 +296,7 @@ class AttachmentDownloadWorker(
): ForegroundInfo {
val notification = kotlin.run {
val title = applicationContext.getString(R.string.notification_attachment_download_title)
val channelId = createAttachmentDownloadChannel()
val channelId = createNotificationChannel()
NotificationCompat.Builder(applicationContext, channelId)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.setContentTitle(title)
@ -313,7 +313,7 @@ class AttachmentDownloadWorker(
)
}
private fun createAttachmentDownloadChannel(): String {
private fun createNotificationChannel(): String {
val channel = kotlin.run {
val id =
applicationContext.getString(R.string.notification_attachment_download_channel_id)

View File

@ -0,0 +1,348 @@
package com.artemchep.keyguard.android.downloader.worker
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.artemchep.keyguard.android.Notifications
import com.artemchep.keyguard.android.downloader.receiver.VaultExportActionReceiver
import com.artemchep.keyguard.common.R
import com.artemchep.keyguard.common.io.attempt
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.timeout
import com.artemchep.keyguard.common.io.toIO
import com.artemchep.keyguard.common.model.MasterSession
import com.artemchep.keyguard.common.service.download.DownloadProgress
import com.artemchep.keyguard.common.service.export.ExportManager
import com.artemchep.keyguard.common.usecase.GetVaultSession
import com.artemchep.keyguard.feature.filepicker.humanReadableByteCountSI
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.res.*
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transformWhile
import org.jetbrains.compose.resources.getString
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.instance
import kotlin.math.roundToInt
class ExportWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params), DIAware {
companion object {
private const val WORK_ID = "VaultExportWorker"
private const val PROGRESS_MAX = 100
fun enqueueOnce(
context: Context,
args: Args,
): Operation {
val data = Data.Builder()
.apply {
args.populate(this)
}
.build()
val request = OneTimeWorkRequestBuilder<ExportWorker>()
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
.setInputData(data)
.build()
val workId = buildWorkKey(args.exportId)
return WorkManager
.getInstance(context)
.enqueueUniqueWork(workId, ExistingWorkPolicy.KEEP, request)
}
private fun buildWorkKey(id: String) = "$WORK_ID:$id"
}
data class Args(
val exportId: String,
) {
companion object {
private const val KEY_EXPORT_ID = "export_id"
fun of(data: Data) = Args(
exportId = data.getString(KEY_EXPORT_ID)!!,
)
}
fun populate(builder: Data.Builder) {
builder.putString(KEY_EXPORT_ID, exportId)
}
}
override val di by closestDI { applicationContext }
private val notificationManager = context.getSystemService<NotificationManager>()!!
private var notificationId = Notifications.export.obtainId()
override suspend fun doWork(): Result = run {
val args = Args.of(inputData)
internalDoWork(
notificationId = notificationId,
args = args,
)
}
private suspend fun internalDoWork(
notificationId: Int,
args: Args,
): Result {
val ea: GetVaultSession by instance()
val s = ea.valueOrNull as? MasterSession.Key
?: return Result.success()
val exportManager: ExportManager by s.di.instance()
val exportStatusFlow = exportManager
.statusByExportId(exportId = args.exportId)
kotlin.run {
// ...check if the status is other then None.
val result = exportStatusFlow
.filter { it !is DownloadProgress.None }
.toIO()
.timeout(500L)
.attempt()
.bind()
if (result.isLeft()) {
return Result.success()
}
}
val title = applicationContext.getString(R.string.notification_vault_export_title)
val result = exportStatusFlow
.onStart {
val foregroundInfo = createForegroundInfo(
id = notificationId,
exportId = args.exportId,
name = title,
progress = null,
)
setForeground(foregroundInfo)
}
.onEach { progress ->
when (progress) {
is DownloadProgress.None -> {
val foregroundInfo = createForegroundInfo(
id = notificationId,
exportId = args.exportId,
name = title,
progress = null,
)
setForeground(foregroundInfo)
}
is DownloadProgress.Loading -> {
val downloadedFormatted = progress.downloaded
?.let(::humanReadableByteCountSI)
val totalFormatted = progress.total
?.let(::humanReadableByteCountSI)
val p = progress.percentage
val foregroundInfo = createForegroundInfo(
id = notificationId,
exportId = args.exportId,
name = title,
progress = p,
downloaded = downloadedFormatted,
total = totalFormatted,
)
setForeground(foregroundInfo)
}
is DownloadProgress.Complete -> {
// Do nothing
return@onEach
}
}
}
// complete once we finish the download
.transformWhile { progress ->
emit(progress) // always emit progress
progress !is DownloadProgress.Complete
}
.last()
require(result is DownloadProgress.Complete)
// Send a complete notification.
result.result.fold(
ifLeft = { e ->
sendFailureNotification(
exportId = args.exportId,
)
},
ifRight = {
sendSuccessNotification(
exportId = args.exportId,
)
},
)
return result.result
.fold(
ifLeft = { e ->
// We don't want to automatically retry exporting a
// vault, just notify a user and bail out.
Result.success()
},
ifRight = {
Result.success()
},
)
}
override suspend fun getForegroundInfo() = createForegroundInfo(notificationId)
//
// Notification
//
private suspend fun sendFailureNotification(
exportId: String,
) = sendCompleteNotification(exportId) { builder ->
val name = getString(Res.string.exportaccount_export_failure)
builder
.setContentTitle(name)
.setTicker(name)
.setSmallIcon(android.R.drawable.stat_sys_warning)
}
private suspend fun sendSuccessNotification(
exportId: String,
) = sendCompleteNotification(exportId) { builder ->
val name = getString(Res.string.exportaccount_export_success)
builder
.setContentTitle(name)
.setTicker(name)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
}
private inline fun sendCompleteNotification(
exportId: String,
block: (NotificationCompat.Builder) -> NotificationCompat.Builder,
) {
val channelId = createNotificationChannel()
val notification = NotificationCompat.Builder(applicationContext, channelId)
.run(block)
.setGroup(WORK_ID)
.build()
notificationManager.notify(exportId, notificationId, notification)
}
// Creates an instance of ForegroundInfo which can be used to update the
// ongoing notification.
private fun createForegroundInfo(
id: Int,
exportId: String,
name: String,
progress: Float? = null,
downloaded: String? = null,
total: String? = null,
): ForegroundInfo {
val notification = kotlin.run {
val channelId = createNotificationChannel()
// Progress
val progressMax = PROGRESS_MAX
val progressCurrent = progress?.times(progressMax)?.roundToInt()
?: progressMax
val progressIndeterminate = progress == null
// Action
val cancelAction = kotlin.run {
val cancelAction = kotlin.run {
val intent = VaultExportActionReceiver.cancel(
context = applicationContext,
exportId = exportId,
)
PendingIntent.getBroadcast(
applicationContext,
id,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
val cancelTitle = applicationContext.getString(android.R.string.cancel)
NotificationCompat.Action.Builder(R.drawable.ic_cancel, cancelTitle, cancelAction)
.build()
}
NotificationCompat.Builder(applicationContext, channelId)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.addAction(cancelAction)
.setContentTitle(name)
.setGroup(WORK_ID)
.setTicker(name)
.run {
if (downloaded != null || total != null) {
val downloadedOrEmpty = downloaded ?: "--"
val totalOrEmpty = total ?: "--"
val info = "$downloadedOrEmpty / $totalOrEmpty"
setContentText(info)
} else {
this
}
}
.setProgress(progressMax, progressCurrent, progressIndeterminate)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOnlyAlertOnce(true)
.setOngoing(true)
.build()
}
return ForegroundInfo(
id,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
private fun createForegroundInfo(
id: Int,
): ForegroundInfo {
val notification = kotlin.run {
val title = applicationContext.getString(R.string.notification_vault_export_title)
val channelId = createNotificationChannel()
NotificationCompat.Builder(applicationContext, channelId)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.setContentTitle(title)
.setGroup(WORK_ID)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOnlyAlertOnce(true)
.setOngoing(true)
.build()
}
return ForegroundInfo(
id,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
private fun createNotificationChannel(): String {
val channel = kotlin.run {
val id =
applicationContext.getString(R.string.notification_vault_export_channel_id)
val name =
applicationContext.getString(R.string.notification_vault_export_channel_name)
NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH)
}
channel.enableVibration(false)
notificationManager.createNotificationChannel(channel)
return channel.id
}
}

View File

@ -52,10 +52,15 @@ class DirsServiceAndroid(
// Ensure the parent directory does exist
// before writing the file.
file.parentFile?.mkdirs()
file.outputStream()
.use {
write(it)
}
try {
file.outputStream()
.use {
write(it)
}
} catch (e: Exception) {
file.delete()
throw e
}
}
@RequiresApi(Build.VERSION_CODES.Q)
@ -112,8 +117,8 @@ class DirsServiceAndroid(
values.put(MediaStore.MediaColumns.IS_PENDING, false)
contentResolver.update(fileUri, values, null, null)
} catch (e: Exception) {
e.printStackTrace()
contentResolver.delete(fileUri, null, null)
throw e
}
}

View File

@ -5,6 +5,8 @@ import android.content.Context
import android.content.pm.PackageManager
import com.artemchep.keyguard.android.downloader.DownloadClientAndroid
import com.artemchep.keyguard.android.downloader.DownloadManagerImpl
import com.artemchep.keyguard.android.downloader.DownloadTaskAndroid
import com.artemchep.keyguard.android.downloader.ExportManagerImpl
import com.artemchep.keyguard.android.downloader.journal.DownloadRepository
import com.artemchep.keyguard.android.downloader.journal.DownloadRepositoryImpl
import com.artemchep.keyguard.android.downloader.journal.room.DownloadDatabaseManager
@ -14,6 +16,8 @@ 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.download.DownloadTask
import com.artemchep.keyguard.common.service.export.ExportManager
import com.artemchep.keyguard.common.service.keyvalue.KeyValueStore
import com.artemchep.keyguard.common.service.logging.LogRepository
import com.artemchep.keyguard.common.service.permission.PermissionService
@ -55,6 +59,7 @@ import com.artemchep.keyguard.copy.SharedPreferencesStoreFactory
import com.artemchep.keyguard.copy.SharedPreferencesStoreFactoryDefault
import com.artemchep.keyguard.copy.SubscriptionServiceAndroid
import com.artemchep.keyguard.copy.TextServiceAndroid
import com.artemchep.keyguard.copy.download.DownloadTaskJvm
import com.artemchep.keyguard.core.session.usecase.BiometricStatusUseCaseImpl
import com.artemchep.keyguard.core.session.usecase.GetLocaleAndroid
import com.artemchep.keyguard.core.session.usecase.PutLocaleAndroid
@ -185,6 +190,11 @@ fun diFingerprintRepositoryModule() = DI.Module(
directDI = this,
)
}
bindSingleton<DownloadTask> {
DownloadTaskAndroid(
directDI = this,
)
}
bindSingleton<DownloadManager> {
DownloadManagerImpl(
directDI = this,

View File

@ -7,6 +7,7 @@ import androidx.sqlite.db.SupportSQLiteOpenHelper
import app.cash.sqldelight.db.AfterVersion
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.artemchep.keyguard.android.downloader.ExportManagerImpl
import com.artemchep.keyguard.android.downloader.journal.room.DownloadDatabaseManager
import com.artemchep.keyguard.common.NotificationsWorker
import com.artemchep.keyguard.common.io.IO
@ -14,6 +15,7 @@ import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.flatMap
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.model.MasterKey
import com.artemchep.keyguard.common.service.export.ExportManager
import com.artemchep.keyguard.common.usecase.QueueSyncAll
import com.artemchep.keyguard.common.usecase.QueueSyncById
import com.artemchep.keyguard.copy.QueueSyncAllAndroid
@ -44,6 +46,11 @@ actual fun DI.Builder.createSubDi(
bindSingleton<QueueSyncById> {
QueueSyncByIdAndroid(this)
}
bindSingleton<ExportManager> {
ExportManagerImpl(
directDI = this,
)
}
bindSingleton<NotificationsWorker> {
NotificationsImpl(this)

View File

@ -1,19 +1,23 @@
<resources>
<string name="app_name">Keyguard</string>
<string name="notification_attachment_upload_channel_id">com.artemchep.keyguard.UPLOAD_ATTACHMENTS</string>
<string name="notification_attachment_upload_channel_id" translatable="false">com.artemchep.keyguard.UPLOAD_ATTACHMENTS</string>
<string name="notification_attachment_upload_channel_name">Uploading attachments</string>
<string name="notification_attachment_upload_title">Uploading attachments</string>
<string name="notification_attachment_upload_content">Uploading attachments</string>
<string name="notification_attachment_download_channel_id">com.artemchep.keyguard.DOWNLOAD_ATTACHMENTS</string>
<string name="notification_attachment_download_channel_id" translatable="false">com.artemchep.keyguard.DOWNLOAD_ATTACHMENTS</string>
<string name="notification_attachment_download_channel_name">Download attachments</string>
<string name="notification_attachment_download_title">Downloading attachments</string>
<string name="notification_clipboard_channel_id">com.artemchep.keyguard.CLIPBOARD</string>
<string name="notification_vault_export_channel_id" translatable="false">com.artemchep.keyguard.EXPORT_VAULT</string>
<string name="notification_vault_export_channel_name">Export</string>
<string name="notification_vault_export_title">Exporting vault</string>
<string name="notification_clipboard_channel_id" translatable="false">com.artemchep.keyguard.CLIPBOARD</string>
<string name="notification_clipboard_channel_name">Clipboard</string>
<string name="notification_sync_vault_channel_id">com.artemchep.keyguard.SYNC_VAULT</string>
<string name="notification_sync_vault_channel_id" translatable="false">com.artemchep.keyguard.SYNC_VAULT</string>
<string name="notification_sync_vault_channel_name">Sync vault</string>
<string name="notification_sync_vault_title">Syncing vault</string>

View File

@ -916,9 +916,12 @@
<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_attachments_note">Vault will be kept unlocked during the export process. The attachments are referenced in the vault's data. The attachments are never stored in an unencrypted form in the process.</string>
<string name="exportaccount_include_attachments_title">Export attachments</string>
<string name="exportaccount_export_button">Export</string>
<string name="exportaccount_export_started">Export started</string>
<string name="exportaccount_export_success">Export complete</string>
<string name="exportaccount_export_failure">Export failed</string>
<string name="contactus_header_title">Contact us</string>
<string name="contactus_message_label">Your message</string>

View File

@ -0,0 +1,7 @@
package com.artemchep.keyguard.common.service.download
import java.io.File
fun interface CacheDirProvider {
suspend fun get(): File
}

View File

@ -0,0 +1,11 @@
package com.artemchep.keyguard.common.service.download
import kotlinx.coroutines.flow.Flow
interface DownloadTask {
fun fileLoader(
url: String,
key: ByteArray?,
writer: DownloadWriter,
): Flow<DownloadProgress>
}

View File

@ -0,0 +1,14 @@
package com.artemchep.keyguard.common.service.download
import java.io.File
import java.io.OutputStream
sealed interface DownloadWriter {
data class FileWriter(
val file: File,
) : DownloadWriter
data class StreamWriter(
val outputStream: OutputStream,
) : DownloadWriter
}

View File

@ -0,0 +1,18 @@
package com.artemchep.keyguard.common.service.export
import com.artemchep.keyguard.common.service.download.DownloadProgress
import com.artemchep.keyguard.common.service.export.model.ExportRequest
import kotlinx.coroutines.flow.Flow
interface ExportManager {
fun statusByExportId(exportId: String): Flow<DownloadProgress>
class QueueResult(
val exportId: String,
val flow: Flow<DownloadProgress>,
)
suspend fun queue(
request: ExportRequest,
): QueueResult
}

View File

@ -5,7 +5,11 @@ import com.artemchep.keyguard.common.model.DFolder
import com.artemchep.keyguard.common.model.DOrganization
import com.artemchep.keyguard.common.model.DSecret
interface ExportService {
interface JsonExportService {
/**
* Exports given content into an extended Bitwarden
* JSON export format.
*/
fun export(
organizations: List<DOrganization>,
collections: List<DCollection>,

View File

@ -0,0 +1,475 @@
package com.artemchep.keyguard.common.service.export.impl
import arrow.core.left
import arrow.core.right
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.throwIfFatalOrCancellation
import com.artemchep.keyguard.common.model.DCollection
import com.artemchep.keyguard.common.model.DFilter
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.model.DownloadAttachmentRequest
import com.artemchep.keyguard.common.model.fileName
import com.artemchep.keyguard.common.model.fileSize
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.dirs.DirsService
import com.artemchep.keyguard.common.service.download.DownloadProgress
import com.artemchep.keyguard.common.service.download.DownloadTask
import com.artemchep.keyguard.common.service.download.DownloadWriter
import com.artemchep.keyguard.common.service.export.ExportManager
import com.artemchep.keyguard.common.service.export.JsonExportService
import com.artemchep.keyguard.common.service.export.model.ExportRequest
import com.artemchep.keyguard.common.service.session.VaultSessionLocker
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.DownloadAttachmentMetadata
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.WindowCoroutineScope
import com.artemchep.keyguard.common.util.flow.EventFlow
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import org.kodein.di.DirectDI
import org.kodein.di.instance
import java.io.File
import kotlin.concurrent.Volatile
open class ExportManagerBase(
private val directDI: DirectDI,
private val windowCoroutineScope: WindowCoroutineScope,
private val cryptoGenerator: CryptoGenerator,
private val jsonExportService: JsonExportService,
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,
private val downloadTask: DownloadTask,
private val downloadAttachmentMetadata: DownloadAttachmentMetadata,
private val vaultSessionLocker: VaultSessionLocker,
private val onLaunch: ExportManager.(String) -> Unit,
) : ExportManager {
private data class PoolEntry(
val id: String,
val scope: CoroutineScope,
val flow: Flow<DownloadProgress>,
)
private val sink =
MutableStateFlow(persistentMapOf<String, PoolEntry>())
private val mutex = Mutex()
private val flowOfNone = flowOf(DownloadProgress.None)
constructor(
directDI: DirectDI,
onLaunch: ExportManager.(String) -> Unit,
) : this(
directDI = directDI,
windowCoroutineScope = directDI.instance(),
cryptoGenerator = directDI.instance(),
jsonExportService = directDI.instance(),
dirsService = directDI.instance(),
zipService = directDI.instance(),
dateFormatter = directDI.instance(),
getOrganizations = directDI.instance(),
getCollections = directDI.instance(),
getFolders = directDI.instance(),
getCiphers = directDI.instance(),
downloadTask = directDI.instance(),
downloadAttachmentMetadata = directDI.instance(),
vaultSessionLocker = directDI.instance(),
onLaunch = onLaunch,
)
private fun fileStatusBy(predicate: (PoolEntry) -> Boolean) = sink
.map { state ->
val entryOrNull = state.values.firstOrNull(predicate)
entryOrNull?.flow
?: flowOfNone
}
.distinctUntilChanged()
.flatMapLatest { it }
override fun statusByExportId(
exportId: String,
): Flow<DownloadProgress> = fileStatusBy { it.id == exportId }
override suspend fun queue(
request: ExportRequest,
): ExportManager.QueueResult {
val entry = invoke2(
filter = request.filter,
password = request.password,
exportAttachments = request.attachments,
)
onLaunch(entry.id)
return ExportManager.QueueResult(
exportId = entry.id,
flow = entry.flow,
)
}
private suspend fun invoke2(
filter: DFilter,
password: String,
exportAttachments: Boolean,
) = kotlin.run {
val id = cryptoGenerator.uuid()
val sharedScope = windowCoroutineScope + SupervisorJob()
val sharedFlow = flow {
val internalFlow = channelFlow<DownloadProgress> {
val result = try {
invoke(
filter = filter,
password = password,
exportAttachments = exportAttachments,
)
} catch (e: Exception) {
e.throwIfFatalOrCancellation()
val result = e.left()
DownloadProgress.Complete(
result = result,
)
}
send(result)
}
try {
emitAll(internalFlow)
} finally {
// Remove the export job
withContext(NonCancellable) {
mutex.withLock {
sink.update { state ->
state.remove(id)
}
}
}
}
}
.onStart {
val event = DownloadProgress.Loading()
emit(event)
}
.shareIn(sharedScope, SharingStarted.Eagerly, replay = 1)
val finalFlow = channelFlow<DownloadProgress> {
val job = sharedScope.launch {
// Keep the session alive while the vault is
// being exported.
launch {
vaultSessionLocker.keepAlive()
}
try {
sharedFlow
.onEach { status -> send(status) }
.collect()
} finally {
// The scope is dead, but the flow is still alive, therefore
// someone has canceled the scope.
if (!this@channelFlow.isClosedForSend) {
val event = DownloadProgress.Complete(
result = RuntimeException("Canceled").left(),
)
trySend(event)
}
}
}
job.join()
}
.transformWhile { progress ->
emit(progress) // always emit progress
progress is DownloadProgress.Loading
}
val entry = PoolEntry(
id = id,
scope = sharedScope,
flow = finalFlow,
)
mutex.withLock {
sink.update { state ->
state.put(id, entry)
}
}
entry
}
private suspend fun ProducerScope<DownloadProgress>.invoke(
filter: DFilter,
password: String,
exportAttachments: Boolean,
): DownloadProgress.Complete {
val data = createExportData(directDI, filter)
// Map vault data to the JSON export
// in the target type.
val json = jsonExportService.export(
organizations = data.organizations,
collections = data.collections,
folders = data.folders,
ciphers = data.ciphers,
)
// Obtain a list of attachments to
// download.
val attachments = if (exportAttachments) {
createAttachmentList(data.ciphers)
} else {
null
}
val fileName = kotlin.run {
val now = Clock.System.now()
val dt = dateFormatter.formatDateTimeMachine(now)
"keyguard_export_$dt.zip"
}
coroutineScope {
val eventFlow = EventFlow<Unit>()
val monitorJob = launch {
// No need to report the progress is there
// are no attachments to download.
attachments
?: return@launch
eventFlow
.onEach {
val event = DownloadProgress.Loading(
downloaded = attachments.downloaded(),
total = attachments.total,
)
trySend(event)
}
.collect()
}
dirsService.saveToDownloads(fileName) { os ->
val entriesAttachments = attachments?.attachments.orEmpty()
.map { entry ->
createDownloadFileZipEntry(
entry = entry,
onDownloadUpdated = { eventFlow.emit(Unit) },
)
}
val entries = listOf(
ZipEntry(
name = "vault.json",
data = ZipEntry.Data.In {
json.byteInputStream()
},
),
) + entriesAttachments
zipService.zip(
outputStream = os,
config = ZipConfig(
encryption = ZipConfig.Encryption(
password = password,
),
),
entries = entries,
)
}.bind()
monitorJob.cancelAndJoin()
}
return DownloadProgress.Complete(File(".").right())
}
private fun createDownloadFileZipEntry(
entry: AttachmentWithLiveProgress,
onDownloadUpdated: () -> Unit,
): ZipEntry {
val cipher = entry.cipher
val attachment = entry.attachment
val data = ZipEntry.Data.Out {
val writer = DownloadWriter.StreamWriter(it)
val request = DownloadAttachmentRequest.ByLocalCipherAttachment(
localCipherId = cipher.id,
remoteCipherId = cipher.service.remote?.id,
attachmentId = attachment.id,
)
val meta = downloadAttachmentMetadata(request)
.bind()
downloadTask.fileLoader(
url = meta.url,
key = meta.encryptionKey,
writer = writer,
)
.onEach { progress ->
val downloaded = when (progress) {
is DownloadProgress.None -> {
// Do nothing.
return@onEach
}
is DownloadProgress.Loading -> {
progress.downloaded
}
is DownloadProgress.Complete -> {
entry.total
}
}
if (downloaded != null) {
entry.downloaded = downloaded
onDownloadUpdated()
}
}
.last()
}
return ZipEntry(
name = "attachments/${attachment.id}/${attachment.fileName()}",
data = data,
)
}
private class ExportData(
val ciphers: List<DSecret>,
val folders: List<DFolder>,
val collections: List<DCollection>,
val organizations: List<DOrganization>,
)
private suspend fun createExportData(
directDI: DirectDI,
filter: DFilter,
): ExportData {
val ciphers = getCiphersByFilter(directDI, 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()
}
return ExportData(
ciphers = ciphers,
folders = folders,
collections = collections,
organizations = organizations,
)
}
private suspend fun getCiphersByFilter(
directDI: DirectDI,
filter: DFilter,
) = getCiphers()
.map { ciphers ->
val predicate = filter.prepare(directDI, ciphers)
ciphers
.filter(predicate)
}
.first()
private class AttachmentWithLiveProgress(
val cipher: DSecret,
val attachment: DSecret.Attachment,
@Volatile
var downloaded: Long,
val total: Long,
)
private class AttachmentList(
val attachments: List<AttachmentWithLiveProgress>,
val total: Long,
) {
/**
* Compute a current number of downloaded
* bytes. The value is not static.
*/
fun downloaded() = attachments.sumOf { it.downloaded.coerceAtMost(it.total) }
}
private fun createAttachmentList(
ciphers: List<DSecret>,
): AttachmentList {
val attachments = ciphers
.flatMap { cipher ->
cipher
.attachments
.map { attachment ->
AttachmentWithLiveProgress(
cipher = cipher,
attachment = attachment,
downloaded = 0L,
total = attachment.fileSize() ?: 0L,
)
}
}
return AttachmentList(
attachments = attachments,
total = attachments.sumOf { it.total },
)
}
}

View File

@ -4,7 +4,7 @@ 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.JsonExportService
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
@ -36,9 +36,9 @@ import kotlinx.serialization.json.putJsonArray
import org.kodein.di.DirectDI
import org.kodein.di.instance
class ExportServiceImpl(
class JsonExportServiceImpl(
private val json: Json,
) : ExportService {
) : JsonExportService {
constructor(
directDI: DirectDI,
) : this(
@ -156,7 +156,7 @@ class ExportServiceImpl(
putJsonArray(key) {
remoteAttachments.forEach { attachment ->
val obj = buildJsonObject {
put("id", attachment.remoteCipherId)
put("id", attachment.id)
put("size", attachment.size)
put("fileName", attachment.fileName)
}

View File

@ -0,0 +1,9 @@
package com.artemchep.keyguard.common.service.export.model
import com.artemchep.keyguard.common.model.DFilter
data class ExportRequest(
val filter: DFilter,
val password: String,
val attachments: Boolean,
)

View File

@ -0,0 +1,81 @@
package com.artemchep.keyguard.common.service.session
import com.artemchep.keyguard.common.io.attempt
import com.artemchep.keyguard.common.io.effectMap
import com.artemchep.keyguard.common.io.flatten
import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.io.toIO
import com.artemchep.keyguard.common.model.MasterSession
import com.artemchep.keyguard.common.usecase.GetVaultLockAfterTimeout
import com.artemchep.keyguard.common.usecase.PutVaultSession
import com.artemchep.keyguard.feature.localization.textResource
import com.artemchep.keyguard.platform.LeContext
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.res.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.suspendCancellableCoroutine
import org.kodein.di.DirectDI
import org.kodein.di.instance
class VaultSessionLocker(
val getVaultLockAfterTimeout: GetVaultLockAfterTimeout,
val putVaultSession: PutVaultSession,
private val scope: CoroutineScope,
private val context: LeContext,
) {
companion object {
private const val DEBOUNCE_MS = 1000L
}
private var clearVaultSessionJob: Job? = null
/**
* A flow that keeps the vault session alive. Once the flow is not active anymore,
* the clear vault session job spawns.
*/
private val keepAliveFlow = flow<Unit> {
clearVaultSessionJob?.cancel()
clearVaultSessionJob = null
try {
// suspend forever
suspendCancellableCoroutine<Unit> { }
} finally {
clearVaultSessionJob = getVaultLockAfterTimeout()
.toIO()
// Wait for the timeout duration.
.effectMap { duration ->
delay(duration)
duration
}
.effectMap {
// Clear the current session.
val session = MasterSession.Empty(
reason = textResource(Res.string.lock_reason_inactivity, context),
)
putVaultSession(session)
}
.flatten()
.attempt()
.launchIn(scope)
}
}.shareIn(scope, SharingStarted.WhileSubscribed(DEBOUNCE_MS))
constructor(directDI: DirectDI) : this(
getVaultLockAfterTimeout = directDI.instance(),
putVaultSession = directDI.instance(),
scope = GlobalScope,
context = directDI.instance(),
)
suspend fun keepAlive() {
keepAliveFlow.collect()
}
}

View File

@ -1,9 +1,19 @@
package com.artemchep.keyguard.common.service.zip
import java.io.InputStream
import java.io.OutputStream
class ZipEntry(
val name: String,
val stream: () -> InputStream,
val data: Data,
) {
sealed interface Data {
data class In(
val stream: suspend () -> InputStream,
) : Data
data class Out(
val stream: suspend (OutputStream) -> Unit,
) : Data
}
}

View File

@ -3,7 +3,7 @@ package com.artemchep.keyguard.common.service.zip
import java.io.OutputStream
interface ZipService {
fun zip(
suspend fun zip(
outputStream: OutputStream,
config: ZipConfig,
entries: List<ZipEntry>,

View File

@ -0,0 +1,7 @@
package com.artemchep.keyguard.common.usecase
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.model.DownloadAttachmentRequest
import com.artemchep.keyguard.common.model.DownloadAttachmentRequestData
interface DownloadAttachmentMetadata : (DownloadAttachmentRequest) -> IO<DownloadAttachmentRequestData>

View File

@ -6,4 +6,5 @@ import com.artemchep.keyguard.common.model.DFilter
interface ExportAccount : (
DFilter,
String,
Boolean,
) -> IO<Unit>

View File

@ -1,39 +1,13 @@
package com.artemchep.keyguard.common.usecase.impl
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.combine
import com.artemchep.keyguard.common.io.flatMap
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.io.map
import com.artemchep.keyguard.common.io.toIO
import com.artemchep.keyguard.common.model.AccountId
import com.artemchep.keyguard.common.model.DownloadAttachmentRequest
import com.artemchep.keyguard.common.model.DownloadAttachmentRequestData
import com.artemchep.keyguard.common.service.crypto.CipherEncryptor
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.download.DownloadService
import com.artemchep.keyguard.common.service.text.Base64Service
import com.artemchep.keyguard.common.usecase.DownloadAttachment
import com.artemchep.keyguard.core.store.DatabaseManager
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
import com.artemchep.keyguard.provider.bitwarden.api.builder.api
import com.artemchep.keyguard.provider.bitwarden.api.builder.get
import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrCta
import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrImpl
import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrKey
import com.artemchep.keyguard.provider.bitwarden.crypto.appendOrganizationToken2
import com.artemchep.keyguard.provider.bitwarden.crypto.appendProfileToken2
import com.artemchep.keyguard.provider.bitwarden.crypto.appendUserToken
import com.artemchep.keyguard.provider.bitwarden.crypto.encrypted
import com.artemchep.keyguard.provider.bitwarden.crypto.transform
import com.artemchep.keyguard.provider.bitwarden.repository.BitwardenCipherRepository
import com.artemchep.keyguard.provider.bitwarden.repository.BitwardenOrganizationRepository
import com.artemchep.keyguard.provider.bitwarden.repository.BitwardenProfileRepository
import com.artemchep.keyguard.provider.bitwarden.repository.BitwardenTokenRepository
import com.artemchep.keyguard.provider.bitwarden.usecase.util.withRefreshableAccessToken
import io.ktor.client.HttpClient
import kotlinx.serialization.json.Json
import com.artemchep.keyguard.common.usecase.DownloadAttachmentMetadata
import org.kodein.di.DirectDI
import org.kodein.di.instance
@ -41,178 +15,25 @@ import org.kodein.di.instance
* @author Artem Chepurnyi
*/
class DownloadAttachmentImpl2(
private val tokenRepository: BitwardenTokenRepository,
private val cipherRepository: BitwardenCipherRepository,
private val profileRepository: BitwardenProfileRepository,
private val organizationRepository: BitwardenOrganizationRepository,
private val downloadAttachmentMetadata: DownloadAttachmentMetadata,
private val downloadService: DownloadService,
private val databaseManager: DatabaseManager,
private val cipherEncryptor: CipherEncryptor,
private val cryptoGenerator: CryptoGenerator,
private val base64Service: Base64Service,
private val json: Json,
private val httpClient: HttpClient,
) : DownloadAttachment {
companion object {
private const val THREAD_BUCKET_SIZE = 10
}
constructor(directDI: DirectDI) : this(
tokenRepository = directDI.instance(),
cipherRepository = directDI.instance(),
profileRepository = directDI.instance(),
organizationRepository = directDI.instance(),
downloadAttachmentMetadata = directDI.instance(),
downloadService = directDI.instance(),
databaseManager = directDI.instance(),
cipherEncryptor = directDI.instance(),
cryptoGenerator = directDI.instance(),
base64Service = directDI.instance(),
json = directDI.instance(),
httpClient = directDI.instance(),
)
override fun invoke(
requests: List<DownloadAttachmentRequest>,
): IO<Unit> = requests
.map { request ->
request
.foo()
downloadAttachmentMetadata(request)
.flatMap(downloadService::download)
}
.combine(bucket = THREAD_BUCKET_SIZE)
.map { Unit }
private fun DownloadAttachmentRequest.foo(): IO<DownloadAttachmentRequestData> = when (this) {
is DownloadAttachmentRequest.ByLocalCipherAttachment -> foo()
}
private fun DownloadAttachmentRequest.ByLocalCipherAttachment.foo() = getLatestAttachmentData(
localCipherId = localCipherId,
remoteCipherId = remoteCipherId,
attachmentId = attachmentId,
)
.map { data ->
DownloadAttachmentRequestData(
localCipherId = localCipherId,
remoteCipherId = remoteCipherId,
attachmentId = attachmentId,
// data
url = data.url,
urlIsOneTime = data.urlIsOneTime,
name = data.name,
encryptionKey = data.encryptionKey,
)
}
private class AttachmentData(
val url: String,
val urlIsOneTime: Boolean,
val name: String,
val encryptionKey: ByteArray,
)
private fun getLatestAttachmentData(
localCipherId: String,
remoteCipherId: String?,
attachmentId: String,
): IO<AttachmentData> = ioEffect {
val cipher = cipherRepository.getById(id = localCipherId).bind()
requireNotNull(cipher)
requireNotNull(remoteCipherId) // can only get attachment info from remote cipher
// Check if actual remote cipher ID matches given
// remote cipher ID.
require(cipher.service.remote?.id == remoteCipherId)
val attachment = cipher.attachments
.asSequence()
.mapNotNull { it as? BitwardenCipher.Attachment.Remote }
.firstOrNull { it.id == attachmentId }
requireNotNull(attachment)
val accountId = AccountId(cipher.accountId)
val token = tokenRepository.getById(id = accountId).bind()
requireNotNull(token)
val profile = profileRepository.getById(id = accountId).toIO().bind()
requireNotNull(profile)
val organizations = organizationRepository.getByAccountId(id = accountId).bind()
// Build cryptography model.
val builder = BitwardenCrImpl(
cipherEncryptor = cipherEncryptor,
cryptoGenerator = cryptoGenerator,
base64Service = base64Service,
).apply {
// We need user keys to decrypt the
// profile key.
appendUserToken(
encKey = base64Service.decode(token.key.encryptionKeyBase64),
macKey = base64Service.decode(token.key.macKeyBase64),
)
appendProfileToken2(
keyData = base64Service.decode(profile.keyBase64),
privateKey = base64Service.decode(profile.privateKeyBase64),
)
organizations.forEach { organization ->
appendOrganizationToken2(
id = organization.organizationId,
keyData = base64Service.decode(organization.keyBase64),
)
}
}
val cr = builder.build()
val envEncryptionType = CipherEncryptor.Type.AesCbc256_HmacSha256_B64
val organizationId: String? = cipher.organizationId
val env = if (organizationId != null) {
val key = BitwardenCrKey.OrganizationToken(organizationId)
BitwardenCrCta.BitwardenCrCtaEnv(
key = key,
encryptionType = envEncryptionType,
)
} else {
val key = BitwardenCrKey.UserToken
BitwardenCrCta.BitwardenCrCtaEnv(
key = key,
encryptionType = envEncryptionType,
)
}
val cta = cr.cta(env, BitwardenCrCta.Mode.DECRYPT)
//
kotlin.runCatching {
val entity = withRefreshableAccessToken(
base64Service = base64Service,
httpClient = httpClient,
json = json,
db = databaseManager,
user = token,
) { latestUser ->
val accessToken = requireNotNull(latestUser.token?.accessToken)
latestUser.env.back().api
.ciphers.focus(id = remoteCipherId)
.attachments.focus(id = attachmentId)
.get(
httpClient = httpClient,
env = latestUser.env.back(),
token = accessToken,
)
}
val model = BitwardenCipher.Attachment
.encrypted(attachment = entity)
.transform(crypto = cta)
AttachmentData(
url = requireNotNull(model.url),
urlIsOneTime = true,
name = model.fileName,
encryptionKey = base64Service.decode(model.keyBase64),
)
}.getOrElse {
AttachmentData(
url = requireNotNull(attachment.url),
urlIsOneTime = false,
name = attachment.fileName,
encryptionKey = base64Service.decode(attachment.keyBase64),
)
}
}
}

View File

@ -0,0 +1,204 @@
package com.artemchep.keyguard.common.usecase.impl
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.io.map
import com.artemchep.keyguard.common.io.toIO
import com.artemchep.keyguard.common.model.AccountId
import com.artemchep.keyguard.common.model.DownloadAttachmentRequest
import com.artemchep.keyguard.common.model.DownloadAttachmentRequestData
import com.artemchep.keyguard.common.service.crypto.CipherEncryptor
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.text.Base64Service
import com.artemchep.keyguard.common.usecase.DownloadAttachmentMetadata
import com.artemchep.keyguard.core.store.DatabaseManager
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
import com.artemchep.keyguard.provider.bitwarden.api.builder.api
import com.artemchep.keyguard.provider.bitwarden.api.builder.get
import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrCta
import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrImpl
import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrKey
import com.artemchep.keyguard.provider.bitwarden.crypto.appendOrganizationToken2
import com.artemchep.keyguard.provider.bitwarden.crypto.appendProfileToken2
import com.artemchep.keyguard.provider.bitwarden.crypto.appendUserToken
import com.artemchep.keyguard.provider.bitwarden.crypto.encrypted
import com.artemchep.keyguard.provider.bitwarden.crypto.transform
import com.artemchep.keyguard.provider.bitwarden.repository.BitwardenCipherRepository
import com.artemchep.keyguard.provider.bitwarden.repository.BitwardenOrganizationRepository
import com.artemchep.keyguard.provider.bitwarden.repository.BitwardenProfileRepository
import com.artemchep.keyguard.provider.bitwarden.repository.BitwardenTokenRepository
import com.artemchep.keyguard.provider.bitwarden.usecase.util.withRefreshableAccessToken
import io.ktor.client.HttpClient
import kotlinx.serialization.json.Json
import org.kodein.di.DirectDI
import org.kodein.di.instance
/**
* @author Artem Chepurnyi
*/
class DownloadAttachmentMetadataImpl2(
private val tokenRepository: BitwardenTokenRepository,
private val cipherRepository: BitwardenCipherRepository,
private val profileRepository: BitwardenProfileRepository,
private val organizationRepository: BitwardenOrganizationRepository,
private val databaseManager: DatabaseManager,
private val cipherEncryptor: CipherEncryptor,
private val cryptoGenerator: CryptoGenerator,
private val base64Service: Base64Service,
private val json: Json,
private val httpClient: HttpClient,
) : DownloadAttachmentMetadata {
constructor(directDI: DirectDI) : this(
tokenRepository = directDI.instance(),
cipherRepository = directDI.instance(),
profileRepository = directDI.instance(),
organizationRepository = directDI.instance(),
databaseManager = directDI.instance(),
cipherEncryptor = directDI.instance(),
cryptoGenerator = directDI.instance(),
base64Service = directDI.instance(),
json = directDI.instance(),
httpClient = directDI.instance(),
)
override fun invoke(
request: DownloadAttachmentRequest,
): IO<DownloadAttachmentRequestData> = request
.foo()
private fun DownloadAttachmentRequest.foo(): IO<DownloadAttachmentRequestData> = when (this) {
is DownloadAttachmentRequest.ByLocalCipherAttachment -> foo()
}
private fun DownloadAttachmentRequest.ByLocalCipherAttachment.foo() = getLatestAttachmentData(
localCipherId = localCipherId,
remoteCipherId = remoteCipherId,
attachmentId = attachmentId,
)
.map { data ->
DownloadAttachmentRequestData(
localCipherId = localCipherId,
remoteCipherId = remoteCipherId,
attachmentId = attachmentId,
// data
url = data.url,
urlIsOneTime = data.urlIsOneTime,
name = data.name,
encryptionKey = data.encryptionKey,
)
}
private class AttachmentData(
val url: String,
val urlIsOneTime: Boolean,
val name: String,
val encryptionKey: ByteArray,
)
private fun getLatestAttachmentData(
localCipherId: String,
remoteCipherId: String?,
attachmentId: String,
): IO<AttachmentData> = ioEffect {
val cipher = cipherRepository.getById(id = localCipherId).bind()
requireNotNull(cipher)
requireNotNull(remoteCipherId) // can only get attachment info from remote cipher
// Check if actual remote cipher ID matches given
// remote cipher ID.
require(cipher.service.remote?.id == remoteCipherId)
val attachment = cipher.attachments
.asSequence()
.mapNotNull { it as? BitwardenCipher.Attachment.Remote }
.firstOrNull { it.id == attachmentId }
requireNotNull(attachment)
val accountId = AccountId(cipher.accountId)
val token = tokenRepository.getById(id = accountId).bind()
requireNotNull(token)
val profile = profileRepository.getById(id = accountId).toIO().bind()
requireNotNull(profile)
val organizations = organizationRepository.getByAccountId(id = accountId).bind()
// Build cryptography model.
val builder = BitwardenCrImpl(
cipherEncryptor = cipherEncryptor,
cryptoGenerator = cryptoGenerator,
base64Service = base64Service,
).apply {
// We need user keys to decrypt the
// profile key.
appendUserToken(
encKey = base64Service.decode(token.key.encryptionKeyBase64),
macKey = base64Service.decode(token.key.macKeyBase64),
)
appendProfileToken2(
keyData = base64Service.decode(profile.keyBase64),
privateKey = base64Service.decode(profile.privateKeyBase64),
)
organizations.forEach { organization ->
appendOrganizationToken2(
id = organization.organizationId,
keyData = base64Service.decode(organization.keyBase64),
)
}
}
val cr = builder.build()
val envEncryptionType = CipherEncryptor.Type.AesCbc256_HmacSha256_B64
val organizationId: String? = cipher.organizationId
val env = if (organizationId != null) {
val key = BitwardenCrKey.OrganizationToken(organizationId)
BitwardenCrCta.BitwardenCrCtaEnv(
key = key,
encryptionType = envEncryptionType,
)
} else {
val key = BitwardenCrKey.UserToken
BitwardenCrCta.BitwardenCrCtaEnv(
key = key,
encryptionType = envEncryptionType,
)
}
val cta = cr.cta(env, BitwardenCrCta.Mode.DECRYPT)
//
kotlin.runCatching {
val entity = withRefreshableAccessToken(
base64Service = base64Service,
httpClient = httpClient,
json = json,
db = databaseManager,
user = token,
) { latestUser ->
val accessToken = requireNotNull(latestUser.token?.accessToken)
latestUser.env.back().api
.ciphers.focus(id = remoteCipherId)
.attachments.focus(id = attachmentId)
.get(
httpClient = httpClient,
env = latestUser.env.back(),
token = accessToken,
)
}
val model = BitwardenCipher.Attachment
.encrypted(attachment = entity)
.transform(crypto = cta)
AttachmentData(
url = requireNotNull(model.url),
urlIsOneTime = true,
name = model.fileName,
encryptionKey = base64Service.decode(model.keyBase64),
)
}.getOrElse {
it.printStackTrace()
AttachmentData(
url = requireNotNull(attachment.url),
urlIsOneTime = false,
name = attachment.fileName,
encryptionKey = base64Service.decode(attachment.keyBase64),
)
}
}
}

View File

@ -66,6 +66,7 @@ import com.artemchep.keyguard.common.usecase.CipherUnsecureUrlAutoFix
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.DownloadAttachmentMetadata
import com.artemchep.keyguard.common.usecase.EditWordlist
import com.artemchep.keyguard.common.usecase.ExportAccount
import com.artemchep.keyguard.common.usecase.ExportLogs
@ -139,6 +140,7 @@ import com.artemchep.keyguard.common.usecase.impl.AddEmailRelayImpl
import com.artemchep.keyguard.common.usecase.impl.AddGeneratorHistoryImpl
import com.artemchep.keyguard.common.usecase.impl.AddUrlOverrideImpl
import com.artemchep.keyguard.common.usecase.impl.DownloadAttachmentImpl2
import com.artemchep.keyguard.common.usecase.impl.DownloadAttachmentMetadataImpl2
import com.artemchep.keyguard.common.usecase.impl.EditWordlistImpl
import com.artemchep.keyguard.common.usecase.impl.GetAccountStatusImpl
import com.artemchep.keyguard.common.usecase.impl.GetBreachesImpl
@ -276,6 +278,12 @@ fun DI.Builder.createSubDi2(
bindSingleton<DownloadAttachment> {
DownloadAttachmentImpl2(this)
}
bindSingleton<DownloadAttachmentMetadata> {
DownloadAttachmentMetadataImpl2(this)
}
bindSingleton<ExportAccount> {
ExportAccountImpl(this)
}
bindSingleton<GetCanAddAccount> {
GetCanAddAccountImpl(this)
}
@ -533,9 +541,6 @@ fun DI.Builder.createSubDi2(
bindSingleton<AddFolder> {
AddFolderImpl(this)
}
bindSingleton<ExportAccount> {
ExportAccountImpl(this)
}
bindSingleton<ExportLogs> {
ExportLogsImpl(this)
}

View File

@ -1,6 +1,7 @@
package com.artemchep.keyguard.feature.export
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
@ -19,6 +20,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
@ -32,6 +34,7 @@ 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 androidx.compose.ui.zIndex
import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.service.permission.PermissionState
import com.artemchep.keyguard.feature.home.vault.model.FilterItem
@ -47,11 +50,14 @@ 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.FlatItemLayout
import com.artemchep.keyguard.ui.FlatItemTextContent
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.KeyguardAttachment
import com.artemchep.keyguard.ui.icons.KeyguardCipher
import com.artemchep.keyguard.ui.icons.icon
import com.artemchep.keyguard.ui.skeleton.SkeletonTextField
@ -64,6 +70,7 @@ import com.artemchep.keyguard.ui.theme.warningContainer
import com.artemchep.keyguard.ui.toolbar.LargeToolbar
import com.artemchep.keyguard.ui.toolbar.SmallToolbar
import com.artemchep.keyguard.ui.toolbar.util.ToolbarBehavior
import com.artemchep.keyguard.ui.util.HorizontalDivider
import org.jetbrains.compose.resources.stringResource
import kotlinx.collections.immutable.persistentListOf
@ -154,6 +161,7 @@ fun ExportScreenOk(
state: ExportState,
) {
val items by state.itemsFlow.collectAsState()
val attachments by state.attachmentsFlow.collectAsState()
val filter by state.filterFlow.collectAsState()
val password by state.passwordFlow.collectAsState()
val content by state.contentFlow.collectAsState()
@ -195,6 +203,7 @@ fun ExportScreenOk(
ExportScreen(
modifier = modifier,
items = items,
attachments = attachments,
filter = filter,
password = password,
content = content,
@ -251,6 +260,7 @@ private fun ExportScreenFilterButton(
private fun ExportScreen(
modifier: Modifier,
items: ExportState.Items? = null,
attachments: ExportState.Attachments? = null,
filter: ExportState.Filter? = null,
password: ExportState.Password? = null,
content: ExportState.Content? = null,
@ -328,39 +338,47 @@ private fun ExportScreen(
ExportContentSkeleton()
} else if (
items != null &&
attachments != null &&
password != null &&
content != null
) {
ExportContentOk(
items = items,
attachments = attachments,
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.string.exportaccount_no_attachments_note),
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current
.combineAlpha(alpha = MediumEmphasisAlpha),
)
ExpandedIfNotEmpty(
Unit.takeIf { attachments?.enabled == true },
) {
Column {
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.string.exportaccount_attachments_note),
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current
.combineAlpha(alpha = MediumEmphasisAlpha),
)
}
}
}
}
@ -377,6 +395,7 @@ private fun ColumnScope.ExportContentSkeleton(
@Composable
private fun ColumnScope.ExportContentOk(
items: ExportState.Items,
attachments: ExportState.Attachments,
password: ExportState.Password,
content: ExportState.Content,
) {
@ -452,4 +471,45 @@ private fun ColumnScope.ExportContentOk(
},
onClick = items.onView,
)
if (attachments.onToggle != null) {
HorizontalDivider(
modifier = Modifier
.padding(vertical = 8.dp),
)
FlatItemLayout(
leading = {
BadgedBox(
modifier = Modifier
.zIndex(20f),
badge = {
val size = attachments.size
?: return@BadgedBox
Badge(
containerColor = MaterialTheme.colorScheme.badgeContainer,
) {
Text(text = size)
}
},
) {
Icon(Icons.Outlined.KeyguardAttachment, null)
}
},
content = {
FlatItemTextContent(
title = {
Text(
text = stringResource(Res.string.exportaccount_include_attachments_title),
)
},
)
},
trailing = {
Switch(
checked = attachments.enabled,
onCheckedChange = null,
)
},
onClick = attachments.onToggle,
)
}
}

View File

@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.StateFlow
data class ExportState(
val itemsFlow: StateFlow<Items>,
val attachmentsFlow: StateFlow<Attachments>,
val filterFlow: StateFlow<Filter>,
val passwordFlow: StateFlow<Password>,
val contentFlow: StateFlow<Content>,
@ -36,4 +37,15 @@ data class ExportState(
val count: Int,
val onView: (() -> Unit)? = null,
)
@Immutable
data class Attachments(
val revision: Int,
val list: List<DSecret.Attachment>,
val size: String? = null,
val count: Int,
val onView: (() -> Unit)? = null,
val enabled: Boolean,
val onToggle: (() -> Unit)? = null,
)
}

View File

@ -4,10 +4,14 @@ 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.ioEffect
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.model.fileSize
import com.artemchep.keyguard.common.service.export.ExportManager
import com.artemchep.keyguard.common.service.export.model.ExportRequest
import com.artemchep.keyguard.common.service.permission.Permission
import com.artemchep.keyguard.common.service.permission.PermissionService
import com.artemchep.keyguard.common.service.permission.PermissionState
@ -22,6 +26,7 @@ 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.filepicker.humanReadableByteCountSI
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
@ -58,7 +63,7 @@ fun produceExportScreenState(
getCollections = instance(),
getOrganizations = instance(),
permissionService = instance(),
exportAccount = instance(),
exportManager = instance(),
)
}
@ -78,11 +83,15 @@ fun produceExportScreenState(
getCollections: GetCollections,
getOrganizations: GetOrganizations,
permissionService: PermissionService,
exportAccount: ExportAccount,
exportManager: ExportManager,
): Loadable<ExportState> = produceScreenState(
key = "export",
initial = Loadable.Loading,
) {
val attachmentsSink = mutablePersistedFlow(
key = "attachments",
) { false }
val passwordSink = mutablePersistedFlow(
key = "password",
) { "" }
@ -91,15 +100,20 @@ fun produceExportScreenState(
fun onExport(
password: String,
filter: DFilter,
attachments: Boolean,
) {
exportAccount(
filter,
password,
val request = ExportRequest(
filter = filter,
password = password,
attachments = attachments,
)
ioEffect {
exportManager.queue(request)
}
.effectTap {
val msg = ToastMessage(
title = translate(Res.string.exportaccount_export_success),
type = ToastMessage.Type.SUCCESS,
title = translate(Res.string.exportaccount_export_started),
type = ToastMessage.Type.INFO,
)
message(msg)
@ -223,6 +237,54 @@ fun produceExportScreenState(
)
}
.stateIn(screenScope)
val attachmentsFlow = filteredCiphersFlow
.map { state ->
val attachments = state.list
.flatMap { it.attachments }
val attachmentsTotalSizeByte = attachments.sumOf { it.fileSize() ?: 0L }
.takeIf { it > 0L }
?.let { humanReadableByteCountSI(it) }
ExportState.Attachments(
revision = state.filterConfig?.id ?: 0,
list = attachments,
size = attachmentsTotalSizeByte,
count = attachments.size,
onView = onClick {
val filter = DFilter.And(
listOfNotNull(
DFilter.ByAttachments,
state.filterConfig?.filter,
),
)
val route = VaultRoute(
args = VaultRoute.Args(
appBar = VaultRoute.Args.AppBar(
title = translate(Res.string.exportaccount_header_title),
),
filter = filter,
trash = false,
preselect = false,
canAddSecrets = false,
),
)
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
enabled = false,
onToggle = null,
)
}
.combine(attachmentsSink) { state, enableAttachments ->
if (state.count == 0) {
return@combine state
}
state.copy(
enabled = enableAttachments,
onToggle = attachmentsSink::value::set
.partially1(!enableAttachments),
)
}
.stateIn(screenScope)
val passwordRawFlow = passwordSink
.validatedPassword(
scope = this,
@ -242,10 +304,11 @@ fun produceExportScreenState(
.stateIn(screenScope)
val contentFlow = combine(
writeDownloadsPermissionFlow,
attachmentsSink,
passwordRawFlow,
filterResult
.filterFlow,
) { writeDownloadsPermission, passwordValidated, filterHolder ->
) { writeDownloadsPermission, enableAttachments, passwordValidated, filterHolder ->
val export = kotlin.run {
val canExport = passwordValidated is Validated.Success &&
writeDownloadsPermission is PermissionState.Granted
@ -263,6 +326,7 @@ fun produceExportScreenState(
::onExport
.partially1(passwordValidated.model)
.partially1(filter)
.partially1(enableAttachments)
} else {
null
}
@ -276,6 +340,7 @@ fun produceExportScreenState(
val state = ExportState(
itemsFlow = itemsFlow,
attachmentsFlow = attachmentsFlow,
filterFlow = filterFlow,
passwordFlow = passwordFlow,
contentFlow = contentFlow,

View File

@ -4,18 +4,24 @@ 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.model.DownloadAttachmentRequest
import com.artemchep.keyguard.common.model.fileName
import com.artemchep.keyguard.common.service.dirs.DirsService
import com.artemchep.keyguard.common.service.export.ExportService
import com.artemchep.keyguard.common.service.download.DownloadTask
import com.artemchep.keyguard.common.service.download.DownloadWriter
import com.artemchep.keyguard.common.service.export.JsonExportService
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.DownloadAttachmentMetadata
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.last
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock
import org.kodein.di.DirectDI
@ -26,7 +32,7 @@ import org.kodein.di.instance
*/
class ExportAccountImpl(
private val directDI: DirectDI,
private val exportService: ExportService,
private val jsonExportService: JsonExportService,
private val dirsService: DirsService,
private val zipService: ZipService,
private val dateFormatter: DateFormatter,
@ -34,6 +40,8 @@ class ExportAccountImpl(
private val getCollections: GetCollections,
private val getFolders: GetFolders,
private val getCiphers: GetCiphers,
private val downloadTask: DownloadTask,
private val downloadAttachmentMetadata: DownloadAttachmentMetadata,
) : ExportAccount {
companion object {
private const val TAG = "ExportAccount.bitwarden"
@ -41,7 +49,7 @@ class ExportAccountImpl(
constructor(directDI: DirectDI) : this(
directDI = directDI,
exportService = directDI.instance(),
jsonExportService = directDI.instance(),
dirsService = directDI.instance(),
zipService = directDI.instance(),
dateFormatter = directDI.instance(),
@ -49,11 +57,14 @@ class ExportAccountImpl(
getCollections = directDI.instance(),
getFolders = directDI.instance(),
getCiphers = directDI.instance(),
downloadTask = directDI.instance(),
downloadAttachmentMetadata = directDI.instance(),
)
override fun invoke(
filter: DFilter,
password: String,
attachments: Boolean,
): IO<Unit> = ioEffect {
val ciphers = getCiphersByFilter(filter)
val folders = kotlin.run {
@ -95,21 +106,52 @@ class ExportAccountImpl(
// Map vault data to the JSON export
// in the target type.
val json = exportService.export(
val json = jsonExportService.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 ->
val entriesAttachments = ciphers.flatMap { cipher ->
cipher.attachments
.map { attachment ->
ZipEntry(
name = "attachments/${attachment.id}/${attachment.fileName()}",
data = ZipEntry.Data.Out {
val writer = DownloadWriter.StreamWriter(it)
val request = DownloadAttachmentRequest.ByLocalCipherAttachment(
localCipherId = cipher.id,
remoteCipherId = cipher.service.remote?.id,
attachmentId = attachment.id,
)
val data = downloadAttachmentMetadata(request)
.bind()
downloadTask.fileLoader(
url = data.url,
key = data.encryptionKey,
writer = writer,
).last().also {
println(it)
}
},
)
}
}
val entries = listOf(
ZipEntry(
name = "vault.json",
data = ZipEntry.Data.In {
json.byteInputStream()
},
),
) + entriesAttachments
zipService.zip(
outputStream = os,
config = ZipConfig(
@ -117,14 +159,7 @@ class ExportAccountImpl(
password = password,
),
),
entries = listOf(
ZipEntry(
name = "vault.json",
stream = {
json.byteInputStream()
},
),
),
entries = entries,
)
}.bind()
}

View File

@ -62,7 +62,7 @@ class ExportLogsImpl(
entries = listOf(
ZipEntry(
name = "logs.txt",
stream = {
data = ZipEntry.Data.In {
txt.byteInputStream()
},
),

View File

@ -1,6 +1,7 @@
package com.artemchep.keyguard.copy
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.crypto.FileEncryptor
import com.artemchep.keyguard.common.usecase.WindowCoroutineScope
import com.artemchep.keyguard.copy.download.DownloadClientJvm
@ -11,6 +12,7 @@ import java.io.File
class DownloadClientDesktop(
private val dataDirectory: DataDirectory,
cryptoGenerator: CryptoGenerator,
windowCoroutineScope: WindowCoroutineScope,
okHttpClient: OkHttpClient,
fileEncryptor: FileEncryptor,
@ -20,6 +22,7 @@ class DownloadClientDesktop(
.bind()
File(path)
},
cryptoGenerator = cryptoGenerator,
windowCoroutineScope = windowCoroutineScope,
okHttpClient = okHttpClient,
fileEncryptor = fileEncryptor,
@ -28,6 +31,7 @@ class DownloadClientDesktop(
directDI: DirectDI,
) : this(
dataDirectory = directDI.instance(),
cryptoGenerator = directDI.instance(),
windowCoroutineScope = directDI.instance(),
okHttpClient = directDI.instance(),
fileEncryptor = directDI.instance(),

View File

@ -0,0 +1,37 @@
package com.artemchep.keyguard.copy
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.crypto.FileEncryptor
import com.artemchep.keyguard.common.usecase.WindowCoroutineScope
import com.artemchep.keyguard.copy.download.DownloadClientJvm
import com.artemchep.keyguard.copy.download.DownloadTaskJvm
import okhttp3.OkHttpClient
import org.kodein.di.DirectDI
import org.kodein.di.instance
import java.io.File
class DownloadTaskDesktop(
private val dataDirectory: DataDirectory,
cryptoGenerator: CryptoGenerator,
okHttpClient: OkHttpClient,
fileEncryptor: FileEncryptor,
) : DownloadTaskJvm(
cacheDirProvider = {
val path = dataDirectory.cache()
.bind()
File(path)
},
cryptoGenerator = cryptoGenerator,
okHttpClient = okHttpClient,
fileEncryptor = fileEncryptor,
) {
constructor(
directDI: DirectDI,
) : this(
dataDirectory = directDI.instance(),
cryptoGenerator = directDI.instance(),
okHttpClient = directDI.instance(),
fileEncryptor = directDI.instance(),
)
}

View File

@ -0,0 +1,79 @@
package com.artemchep.keyguard.copy
import com.artemchep.keyguard.common.io.attempt
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.timeout
import com.artemchep.keyguard.common.io.toIO
import com.artemchep.keyguard.common.model.ToastMessage
import com.artemchep.keyguard.common.service.download.DownloadProgress
import com.artemchep.keyguard.common.service.export.impl.ExportManagerBase
import com.artemchep.keyguard.common.usecase.ShowMessage
import com.artemchep.keyguard.feature.localization.textResource
import com.artemchep.keyguard.platform.LeContext
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.res.*
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.launch
import org.kodein.di.DirectDI
import org.kodein.di.instance
class ExportManagerImpl(
private val directDI: DirectDI,
private val showMessage: ShowMessage,
private val context: LeContext,
) : ExportManagerBase(
directDI = directDI,
onLaunch = { exportId ->
GlobalScope.launch {
val exportStatusFlow = statusByExportId(exportId = exportId)
kotlin.run {
// ...check if the status is other then None.
val result = exportStatusFlow
.filter { it !is DownloadProgress.None }
.toIO()
.timeout(500L)
.attempt()
.bind()
if (result.isLeft()) {
return@launch
}
}
val result = exportStatusFlow
// complete once we finish the download
.transformWhile { progress ->
emit(progress) // always emit progress
progress !is DownloadProgress.Complete
}
.last()
require(result is DownloadProgress.Complete)
result.result.fold(
ifLeft = {
val message = ToastMessage(
title = textResource(Res.string.exportaccount_export_failure, context),
type = ToastMessage.Type.ERROR,
)
showMessage.copy(message)
},
ifRight = {
val message = ToastMessage(
title = textResource(Res.string.exportaccount_export_success, context),
type = ToastMessage.Type.SUCCESS,
)
showMessage.copy(message)
},
)
}
},
) {
constructor(
directDI: DirectDI,
) : this(
directDI = directDI,
showMessage = directDI.instance(),
context = directDI.instance(),
)
}

View File

@ -24,6 +24,8 @@ import com.artemchep.keyguard.common.service.autofill.AutofillServiceStatus
import com.artemchep.keyguard.common.service.clipboard.ClipboardService
import com.artemchep.keyguard.common.service.connectivity.ConnectivityService
import com.artemchep.keyguard.common.service.download.DownloadManager
import com.artemchep.keyguard.common.service.download.DownloadTask
import com.artemchep.keyguard.common.service.export.ExportManager
import com.artemchep.keyguard.common.service.keyvalue.KeyValueStore
import com.artemchep.keyguard.common.service.keyvalue.impl.FileJsonKeyValueStoreStore
import com.artemchep.keyguard.common.service.keyvalue.impl.JsonKeyValueStore
@ -53,11 +55,14 @@ import com.artemchep.keyguard.copy.DataDirectory
import com.artemchep.keyguard.copy.DownloadClientDesktop
import com.artemchep.keyguard.copy.DownloadManagerDesktop
import com.artemchep.keyguard.copy.DownloadRepositoryDesktop
import com.artemchep.keyguard.copy.DownloadTaskDesktop
import com.artemchep.keyguard.copy.ExportManagerImpl
import com.artemchep.keyguard.copy.GetBarcodeImageJvm
import com.artemchep.keyguard.copy.PermissionServiceJvm
import com.artemchep.keyguard.copy.PowerServiceJvm
import com.artemchep.keyguard.copy.ReviewServiceJvm
import com.artemchep.keyguard.copy.TextServiceJvm
import com.artemchep.keyguard.copy.download.DownloadTaskJvm
import com.artemchep.keyguard.di.globalModuleJvm
import com.artemchep.keyguard.platform.LeContext
import com.artemchep.keyguard.util.traverse
@ -272,6 +277,11 @@ fun diFingerprintRepositoryModule() = DI.Module(
directDI = this,
)
}
bindSingleton<DownloadTask> {
DownloadTaskDesktop(
directDI = this,
)
}
bindSingleton<DownloadManager> {
DownloadManagerDesktop(
directDI = this,

View File

@ -6,10 +6,12 @@ import com.artemchep.keyguard.common.io.effectMap
import com.artemchep.keyguard.common.io.ioRaise
import com.artemchep.keyguard.common.model.DownloadAttachmentRequest
import com.artemchep.keyguard.common.model.MasterKey
import com.artemchep.keyguard.common.service.export.ExportManager
import com.artemchep.keyguard.common.usecase.DownloadAttachment
import com.artemchep.keyguard.common.usecase.QueueSyncAll
import com.artemchep.keyguard.common.usecase.QueueSyncById
import com.artemchep.keyguard.copy.DataDirectory
import com.artemchep.keyguard.copy.ExportManagerImpl
import com.artemchep.keyguard.core.store.DatabaseManager
import com.artemchep.keyguard.core.store.DatabaseManagerImpl
import com.artemchep.keyguard.core.store.SqlManagerFile
@ -32,6 +34,11 @@ actual fun DI.Builder.createSubDi(
bindSingleton<QueueSyncById> {
QueueSyncByIdImpl(this)
}
bindSingleton<ExportManager> {
ExportManagerImpl(
directDI = this,
)
}
bindSingleton<NotificationsWorker> {
NotificationsImpl(this)

View File

@ -17,7 +17,7 @@ class ZipServiceJvm(
directDI: DirectDI,
) : this()
override fun zip(
override suspend fun zip(
outputStream: OutputStream,
config: ZipConfig,
entries: List<ZipEntry>,
@ -30,9 +30,14 @@ class ZipServiceJvm(
)
zipStream.putNextEntry(entryParams)
try {
val inputStream = entry.stream()
inputStream.use {
it.copyTo(zipStream)
when (val d = entry.data) {
is ZipEntry.Data.In -> {
d.stream().use { inputStream -> inputStream.copyTo(zipStream) }
}
is ZipEntry.Data.Out -> {
d.stream(zipStream)
}
}
} finally {
zipStream.closeEntry()

View File

@ -5,6 +5,7 @@ import arrow.core.right
import com.artemchep.keyguard.android.downloader.journal.room.DownloadInfoEntity2
import com.artemchep.keyguard.common.exception.HttpException
import com.artemchep.keyguard.common.io.throwIfFatalOrCancellation
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.crypto.FileEncryptor
import com.artemchep.keyguard.common.service.download.DownloadProgress
import com.artemchep.keyguard.common.usecase.WindowCoroutineScope
@ -49,6 +50,7 @@ import java.io.IOException
abstract class DownloadClientJvm(
private val cacheDirProvider: CacheDirProvider,
private val cryptoGenerator: CryptoGenerator,
private val windowCoroutineScope: WindowCoroutineScope,
private val okHttpClient: OkHttpClient,
private val fileEncryptor: FileEncryptor,
@ -81,6 +83,7 @@ abstract class DownloadClientJvm(
cacheDirProvider: CacheDirProvider,
) : this(
cacheDirProvider = cacheDirProvider,
cryptoGenerator = directDI.instance(),
windowCoroutineScope = directDI.instance(),
okHttpClient = directDI.instance(),
fileEncryptor = directDI.instance(),
@ -117,7 +120,6 @@ abstract class DownloadClientJvm(
val internalFlow = internalFileLoader(
url = url,
file = file,
fileId = downloadId,
fileKey = fileKey,
)
@ -210,7 +212,6 @@ abstract class DownloadClientJvm(
private fun internalFileLoader(
url: String,
file: File,
fileId: String,
fileKey: ByteArray? = null,
): Flow<DownloadProgress> = flow {
val exists = file.exists()
@ -227,14 +228,32 @@ abstract class DownloadClientJvm(
file.parentFile?.mkdirs()
val f = channelFlow<DownloadProgress> {
val cacheFile = kotlin.runCatching {
val cacheFileName = cryptoGenerator.uuid() + ".download"
val cacheFileRelativePath = "download_cache/$cacheFileName"
cacheDirProvider.get().resolve(cacheFileRelativePath)
}.getOrElse { e ->
// Report the download as failed if we could not
// resolve a cache file.
val event = DownloadProgress.Complete(
result = e.left(),
)
send(event)
return@channelFlow
}
val result = try {
val cacheFileRelativePath = "download_cache/$fileId.download"
val cacheFile = cacheDirProvider.get().resolve(cacheFileRelativePath)
flap(
src = url,
dst = cacheFile,
)
} catch (e: Exception) {
// Delete cache file in case of
// an error.
runCatching {
cacheFile.delete()
}
e.throwIfFatalOrCancellation()
val result = e.left()

View File

@ -0,0 +1,293 @@
package com.artemchep.keyguard.copy.download
import arrow.core.left
import arrow.core.right
import com.artemchep.keyguard.common.exception.HttpException
import com.artemchep.keyguard.common.io.throwIfFatalOrCancellation
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.crypto.FileEncryptor
import com.artemchep.keyguard.common.service.download.CacheDirProvider
import com.artemchep.keyguard.common.service.download.DownloadProgress
import com.artemchep.keyguard.common.service.download.DownloadTask
import com.artemchep.keyguard.common.service.download.DownloadWriter
import io.ktor.http.HttpStatusCode
import io.ktor.utils.io.core.use
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.kodein.di.DirectDI
import org.kodein.di.instance
import java.io.File
import java.io.IOException
import java.io.OutputStream
open class DownloadTaskJvm(
private val cacheDirProvider: CacheDirProvider,
private val cryptoGenerator: CryptoGenerator,
private val okHttpClient: OkHttpClient,
private val fileEncryptor: FileEncryptor,
) : DownloadTask {
companion object {
private const val DOWNLOAD_PROGRESS_POOLING_PERIOD_MS = 1000L
}
constructor(
directDI: DirectDI,
cacheDirProvider: CacheDirProvider,
) : this(
cacheDirProvider = cacheDirProvider,
cryptoGenerator = directDI.instance(),
okHttpClient = directDI.instance(),
fileEncryptor = directDI.instance(),
)
override fun fileLoader(
url: String,
key: ByteArray?,
writer: DownloadWriter,
): Flow<DownloadProgress> = flow {
val f = channelFlow<DownloadProgress> {
// 1. Create a temp file to write encrypted download into
// we use this file to make the situation where the real file is
// half loaded less likely.
val cacheFile = kotlin.runCatching {
val cacheFileName = cryptoGenerator.uuid() + ".download"
val cacheFileRelativePath = "download_cache/$cacheFileName"
cacheDirProvider.get().resolve(cacheFileRelativePath)
}.getOrElse { e ->
// Report the download as failed if we could not
// resolve a cache file.
val event = DownloadProgress.Complete(
result = e.left(),
)
send(event)
return@channelFlow
}
// 2. Download the encrypted content of a file
// to the temporary file.
val result = try {
flap(
src = url,
dst = cacheFile,
)
} catch (e: Exception) {
// Delete cache file in case of
// an error.
runCatching {
cacheFile.delete()
}
e.throwIfFatalOrCancellation()
val result = e.left()
DownloadProgress.Complete(
result = result,
)
}
send(result)
}
.flatMapConcat { event ->
when (event) {
is DownloadProgress.Complete ->
event.result
.fold(
ifLeft = {
flowOf(event)
},
ifRight = { tmpFile ->
// Decrypt the file and move it to the final
// destination.
flow {
emit(DownloadProgress.Loading())
val result = kotlin
.runCatching {
tmpFile.decryptAndMove(
key = key,
writer = writer,
)
}
.fold(
onFailure = { e ->
e.printStackTrace()
e.left()
},
onSuccess = {
when (writer) {
is DownloadWriter.FileWriter -> writer.file.right()
is DownloadWriter.StreamWriter -> File(".").right()
}
},
)
emit(DownloadProgress.Complete(result))
}
},
)
is DownloadProgress.Loading -> flowOf(event)
is DownloadProgress.None -> flowOf(event)
}
}
emitAll(f)
}
.onStart {
val initialState = DownloadProgress.Loading()
emit(initialState)
}
private suspend fun File.decryptAndMove(
key: ByteArray?,
writer: DownloadWriter,
) = when (writer) {
is DownloadWriter.FileWriter -> decryptAndMove(
key = key,
writer = writer,
)
is DownloadWriter.StreamWriter -> decryptAndMove(
key = key,
writer = writer,
)
}
private suspend fun File.decryptAndMove(
key: ByteArray?,
writer: DownloadWriter.FileWriter,
) = withContext(Dispatchers.IO) {
val dst = writer.file
dst.parentFile?.mkdirs()
dst.delete()
decryptAndMove(
key = key,
stream = dst.outputStream(),
)
}
private suspend fun File.decryptAndMove(
key: ByteArray?,
writer: DownloadWriter.StreamWriter,
) = withContext(Dispatchers.IO) {
decryptAndMove(
key = key,
stream = writer.outputStream,
)
}
private suspend fun File.decryptAndMove(
key: ByteArray?,
stream: OutputStream,
) = withContext(Dispatchers.IO) {
inputStream()
.use { fis ->
if (key != null) {
fileEncryptor
.decode(
input = fis,
key = key,
)
.use { i -> i.copyTo(stream) }
} else {
fis.copyTo(stream)
}
}
}
private suspend fun ProducerScope<DownloadProgress>.flap(
src: String,
dst: File,
): DownloadProgress.Complete {
println("Downloading $src")
val response = kotlin.run {
val request = Request.Builder()
.get()
.url(src)
.build()
okHttpClient.newCall(request).execute()
}
if (!response.isSuccessful) {
val exception = HttpException(
statusCode = HttpStatusCode.fromValue(response.code),
m = response.message,
e = null,
)
val result = exception.left()
return DownloadProgress.Complete(
result = result,
)
}
val responseBody = response.body
?: throw IOException("File is not available!")
//
// Check if the file is already loaded
//
val dstContentLength = dst.length()
val srcContentLength = responseBody.contentLength()
if (dstContentLength == srcContentLength) {
val result = dst.right()
return DownloadProgress.Complete(
result = result,
)
}
dst.delete()
dst.parentFile?.mkdirs()
coroutineScope {
var totalBytesWritten = 0L
val monitorJob = launch {
delay(DOWNLOAD_PROGRESS_POOLING_PERIOD_MS / 2)
while (isActive) {
val event = DownloadProgress.Loading(
downloaded = totalBytesWritten,
total = srcContentLength,
)
trySend(event)
// Wait a bit before the next status update.
delay(DOWNLOAD_PROGRESS_POOLING_PERIOD_MS)
}
}
withContext(Dispatchers.IO) {
responseBody.byteStream().use { inputStream ->
dst.outputStream().use { outputStream ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val bytes = inputStream.read(buffer)
if (bytes != -1) {
outputStream.write(buffer, 0, bytes)
totalBytesWritten += bytes
} else {
break
}
}
}
}
}
monitorJob.cancelAndJoin()
}
val result = dst.right()
return DownloadProgress.Complete(
result = result,
)
}
}

View File

@ -12,8 +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.export.JsonExportService
import com.artemchep.keyguard.common.service.export.impl.JsonExportServiceImpl
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
@ -44,6 +44,7 @@ import com.artemchep.keyguard.common.service.placeholder.impl.UrlPlaceholder
import com.artemchep.keyguard.common.service.relays.di.emailRelayDiModule
import com.artemchep.keyguard.common.service.review.ReviewLog
import com.artemchep.keyguard.common.service.review.impl.ReviewLogImpl
import com.artemchep.keyguard.common.service.session.VaultSessionLocker
import com.artemchep.keyguard.common.service.settings.SettingsReadRepository
import com.artemchep.keyguard.common.service.settings.SettingsReadWriteRepository
import com.artemchep.keyguard.common.service.settings.impl.SettingsRepositoryImpl
@ -601,6 +602,11 @@ fun globalModuleJvm() = DI.Module(
directDI = this,
)
}
bindSingleton<VaultSessionLocker> {
VaultSessionLocker(
directDI = this,
)
}
bindSingleton<PutVaultSession> {
PutVaultSessionImpl(
directDI = this,
@ -1169,8 +1175,8 @@ fun globalModuleJvm() = DI.Module(
directDI = this,
)
}
bindSingleton<ExportService> {
ExportServiceImpl(
bindSingleton<JsonExportService> {
JsonExportServiceImpl(
directDI = this,
)
}

View File

@ -19,20 +19,15 @@ import androidx.compose.ui.window.isTraySupported
import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
import com.artemchep.keyguard.common.AppWorker
import com.artemchep.keyguard.common.io.attempt
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.effectMap
import com.artemchep.keyguard.common.io.flatten
import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.io.toIO
import com.artemchep.keyguard.common.model.MasterSession
import com.artemchep.keyguard.common.model.PersistedSession
import com.artemchep.keyguard.common.model.ToastMessage
import com.artemchep.keyguard.common.service.session.VaultSessionLocker
import com.artemchep.keyguard.common.service.vault.KeyReadWriteRepository
import com.artemchep.keyguard.common.usecase.GetAccounts
import com.artemchep.keyguard.common.usecase.GetCloseToTray
import com.artemchep.keyguard.common.usecase.GetLocale
import com.artemchep.keyguard.common.usecase.GetVaultLockAfterTimeout
import com.artemchep.keyguard.common.usecase.GetVaultPersist
import com.artemchep.keyguard.common.usecase.GetVaultSession
import com.artemchep.keyguard.common.usecase.PutVaultSession
@ -46,14 +41,12 @@ import com.artemchep.keyguard.desktop.util.navigateToFileInFileManager
import com.artemchep.keyguard.feature.favicon.Favicon
import com.artemchep.keyguard.feature.favicon.FaviconUrl
import com.artemchep.keyguard.feature.keyguard.AppRoute
import com.artemchep.keyguard.feature.localization.textResource
import com.artemchep.keyguard.feature.navigation.LocalNavigationBackHandler
import com.artemchep.keyguard.feature.navigation.NavigationController
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.NavigationNode
import com.artemchep.keyguard.feature.navigation.NavigationRouterBackHandler
import com.artemchep.keyguard.platform.CurrentPlatform
import com.artemchep.keyguard.platform.LeContext
import com.artemchep.keyguard.platform.Platform
import com.artemchep.keyguard.platform.lifecycle.LaunchLifecycleProviderEffect
import com.artemchep.keyguard.platform.lifecycle.LeLifecycleState
@ -72,9 +65,7 @@ import io.kamel.image.config.Default
import io.kamel.image.config.LocalKamelConfig
import io.ktor.http.Url
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
@ -83,7 +74,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.datetime.Clock
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
@ -206,36 +196,10 @@ fun main() {
}
// timeout
var timeoutJob: Job? = null
val getVaultLockAfterTimeout: GetVaultLockAfterTimeout by appDi.di.instance()
val vaultSessionLocker: VaultSessionLocker by appDi.di.instance()
processLifecycleProvider.lifecycleStateFlow
.onState(minActiveState = LeLifecycleState.RESUMED) {
timeoutJob?.cancel()
timeoutJob = null
try {
// suspend forever
suspendCancellableCoroutine<Unit> { }
} finally {
timeoutJob = getVaultLockAfterTimeout()
.toIO()
// Wait for the timeout duration.
.effectMap { duration ->
delay(duration)
duration
}
.effectMap {
// Clear the current session.
val context = LeContext()
val session = MasterSession.Empty(
reason = textResource(Res.string.lock_reason_inactivity, context),
)
putVaultSession(session)
}
.flatten()
.attempt()
.launchIn(GlobalScope)
}
vaultSessionLocker.keepAlive()
}
.launchIn(GlobalScope)