From 1d550807960dcc004ff0df268f4ab552465c78a3 Mon Sep 17 00:00:00 2001 From: Artem Chepurnoy Date: Sun, 4 Aug 2024 10:12:23 +0300 Subject: [PATCH] feature: Initial Export with attachments --- .../main/java/com/artemchep/keyguard/Main.kt | 31 +- common/src/androidMain/AndroidManifest.xml | 8 + .../keyguard/android/Notifications.kt | 4 + .../downloader/DownloadClientAndroid.kt | 4 + .../android/downloader/DownloadTaskAndroid.kt | 33 ++ .../android/downloader/ExportManagerImpl.kt | 31 ++ .../receiver/VaultExportActionReceiver.kt | 82 +++ .../worker/AttachmentDownloadWorker.kt | 6 +- .../android/downloader/worker/ExportWorker.kt | 348 +++++++++++++ .../keyguard/copy/DirsServiceAndroid.kt | 15 +- .../session/FingerprintRepositoryModule.kt | 10 + .../core/session/usecase/createSubDi.kt | 7 + .../res/values/strings_android.xml | 12 +- .../composeResources/values/strings.xml | 5 +- .../service/download/CacheDirProvider.kt | 7 + .../common/service/download/DownloadTask.kt | 11 + .../common/service/download/DownloadWriter.kt | 14 + .../common/service/export/ExportManager.kt | 18 + ...{ExportService.kt => JsonExportService.kt} | 6 +- .../service/export/impl/ExportManagerImpl.kt | 475 ++++++++++++++++++ ...erviceImpl.kt => JsonExportServiceImpl.kt} | 8 +- .../service/export/model/ExportRequest.kt | 9 + .../service/session/VaultSessionLocker.kt | 81 +++ .../keyguard/common/service/zip/ZipEntry.kt | 12 +- .../keyguard/common/service/zip/ZipService.kt | 2 +- .../usecase/DownloadAttachmentMetadata.kt | 7 + .../keyguard/common/usecase/ExportAccount.kt | 1 + .../usecase/impl/DownloadAttachmentImpl.kt | 187 +------ .../impl/DownloadAttachmentMetadataImpl.kt | 204 ++++++++ .../keyguard/core/session/usecase/SubDI.kt | 11 +- .../keyguard/feature/export/ExportScreen.kt | 106 +++- .../keyguard/feature/export/ExportState.kt | 12 + .../feature/export/ExportStateProducer.kt | 81 ++- .../bitwarden/usecase/ExportAccountImpl.kt | 63 ++- .../bitwarden/usecase/ExportLogsImpl.kt | 2 +- .../keyguard/copy/DownloadClientDesktop.kt | 4 + .../keyguard/copy/DownloadTaskDesktop.kt | 37 ++ .../keyguard/copy/ExportManagerImpl.kt | 79 +++ .../session/FingerprintRepositoryModule.kt | 10 + .../core/session/usecase/createSubDi.kt | 7 + .../artemchep/keyguard/copy/ZipServiceJvm.kt | 13 +- .../copy/download/DownloadClientJvm.kt | 27 +- .../keyguard/copy/download/DownloadTaskJvm.kt | 293 +++++++++++ .../artemchep/keyguard/di/GlobalModuleJvm.kt | 14 +- .../kotlin/com/artemchep/keyguard/Main.kt | 42 +- 45 files changed, 2108 insertions(+), 331 deletions(-) create mode 100644 common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/DownloadTaskAndroid.kt create mode 100644 common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/ExportManagerImpl.kt create mode 100644 common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/receiver/VaultExportActionReceiver.kt create mode 100644 common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/worker/ExportWorker.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/download/CacheDirProvider.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/download/DownloadTask.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/download/DownloadWriter.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/ExportManager.kt rename common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/{ExportService.kt => JsonExportService.kt} (77%) create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/impl/ExportManagerImpl.kt rename common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/impl/{ExportServiceImpl.kt => JsonExportServiceImpl.kt} (98%) create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/model/ExportRequest.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/session/VaultSessionLocker.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/DownloadAttachmentMetadata.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/DownloadAttachmentMetadataImpl.kt create mode 100644 common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/DownloadTaskDesktop.kt create mode 100644 common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/ExportManagerImpl.kt create mode 100644 common/src/jvmMain/kotlin/com/artemchep/keyguard/copy/download/DownloadTaskJvm.kt diff --git a/androidApp/src/main/java/com/artemchep/keyguard/Main.kt b/androidApp/src/main/java/com/artemchep/keyguard/Main.kt index 8d1011a..1d3a638 100644 --- a/androidApp/src/main/java/com/artemchep/keyguard/Main.kt +++ b/androidApp/src/main/java/com/artemchep/keyguard/Main.kt @@ -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 { } - } 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 diff --git a/common/src/androidMain/AndroidManifest.xml b/common/src/androidMain/AndroidManifest.xml index 0606131..0c546c6 100644 --- a/common/src/androidMain/AndroidManifest.xml +++ b/common/src/androidMain/AndroidManifest.xml @@ -168,6 +168,14 @@ + + + + + + diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/android/Notifications.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/Notifications.kt index d465f86..7023044 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/android/Notifications.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/Notifications.kt @@ -9,4 +9,8 @@ object Notifications { ) val uploads = NotificationIdPool.sequential(20000) val totp = NotificationIdPool.sequential(30000) + val export = NotificationIdPool.sequential( + start = 40000, + endExclusive = 50000, + ) } diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/DownloadClientAndroid.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/DownloadClientAndroid.kt index 1f2f1a5..14f2daf 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/DownloadClientAndroid.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/DownloadClientAndroid.kt @@ -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(), + cryptoGenerator = directDI.instance(), windowCoroutineScope = directDI.instance(), okHttpClient = directDI.instance(), fileEncryptor = directDI.instance(), diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/DownloadTaskAndroid.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/DownloadTaskAndroid.kt new file mode 100644 index 0000000..cf6b425 --- /dev/null +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/DownloadTaskAndroid.kt @@ -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(), + cryptoGenerator = directDI.instance(), + okHttpClient = directDI.instance(), + fileEncryptor = directDI.instance(), + ) +} diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/ExportManagerImpl.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/ExportManagerImpl.kt new file mode 100644 index 0000000..222a9f5 --- /dev/null +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/ExportManagerImpl.kt @@ -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(), + ) +} diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/receiver/VaultExportActionReceiver.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/receiver/VaultExportActionReceiver.kt new file mode 100644 index 0000000..93a26bf --- /dev/null +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/receiver/VaultExportActionReceiver.kt @@ -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) + } + } + } +} diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/worker/AttachmentDownloadWorker.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/worker/AttachmentDownloadWorker.kt index 4c78a6f..b884878 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/worker/AttachmentDownloadWorker.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/worker/AttachmentDownloadWorker.kt @@ -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) diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/worker/ExportWorker.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/worker/ExportWorker.kt new file mode 100644 index 0000000..5ea918b --- /dev/null +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/downloader/worker/ExportWorker.kt @@ -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() + .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()!! + + 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 + } +} diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/DirsServiceAndroid.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/DirsServiceAndroid.kt index 816988f..d3abe6c 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/DirsServiceAndroid.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/DirsServiceAndroid.kt @@ -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 } } diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/core/session/FingerprintRepositoryModule.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/core/session/FingerprintRepositoryModule.kt index 6b54acf..aefb955 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/core/session/FingerprintRepositoryModule.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/core/session/FingerprintRepositoryModule.kt @@ -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 { + DownloadTaskAndroid( + directDI = this, + ) + } bindSingleton { DownloadManagerImpl( directDI = this, diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/core/session/usecase/createSubDi.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/core/session/usecase/createSubDi.kt index 605bcef..c9143a6 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/core/session/usecase/createSubDi.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/core/session/usecase/createSubDi.kt @@ -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 { QueueSyncByIdAndroid(this) } + bindSingleton { + ExportManagerImpl( + directDI = this, + ) + } bindSingleton { NotificationsImpl(this) diff --git a/common/src/androidMain/res/values/strings_android.xml b/common/src/androidMain/res/values/strings_android.xml index 438a1ba..8a21a76 100644 --- a/common/src/androidMain/res/values/strings_android.xml +++ b/common/src/androidMain/res/values/strings_android.xml @@ -1,19 +1,23 @@ Keyguard - com.artemchep.keyguard.UPLOAD_ATTACHMENTS + com.artemchep.keyguard.UPLOAD_ATTACHMENTS Uploading attachments Uploading attachments Uploading attachments - com.artemchep.keyguard.DOWNLOAD_ATTACHMENTS + com.artemchep.keyguard.DOWNLOAD_ATTACHMENTS Download attachments Downloading attachments - com.artemchep.keyguard.CLIPBOARD + com.artemchep.keyguard.EXPORT_VAULT + Export + Exporting vault + + com.artemchep.keyguard.CLIPBOARD Clipboard - com.artemchep.keyguard.SYNC_VAULT + com.artemchep.keyguard.SYNC_VAULT Sync vault Syncing vault diff --git a/common/src/commonMain/composeResources/values/strings.xml b/common/src/commonMain/composeResources/values/strings.xml index 4d7be3e..c5b82d9 100644 --- a/common/src/commonMain/composeResources/values/strings.xml +++ b/common/src/commonMain/composeResources/values/strings.xml @@ -916,9 +916,12 @@ Export items Archive password - Only vault item information will be exported and will not include associated attachments. + 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. + Export attachments Export + Export started Export complete + Export failed Contact us Your message diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/download/CacheDirProvider.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/download/CacheDirProvider.kt new file mode 100644 index 0000000..31374d8 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/download/CacheDirProvider.kt @@ -0,0 +1,7 @@ +package com.artemchep.keyguard.common.service.download + +import java.io.File + +fun interface CacheDirProvider { + suspend fun get(): File +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/download/DownloadTask.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/download/DownloadTask.kt new file mode 100644 index 0000000..12b0955 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/download/DownloadTask.kt @@ -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 +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/download/DownloadWriter.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/download/DownloadWriter.kt new file mode 100644 index 0000000..0fed5ae --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/download/DownloadWriter.kt @@ -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 +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/ExportManager.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/ExportManager.kt new file mode 100644 index 0000000..e502322 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/ExportManager.kt @@ -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 + + class QueueResult( + val exportId: String, + val flow: Flow, + ) + + suspend fun queue( + request: ExportRequest, + ): QueueResult +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/ExportService.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/JsonExportService.kt similarity index 77% rename from common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/ExportService.kt rename to common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/JsonExportService.kt index e78931c..eed9505 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/ExportService.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/JsonExportService.kt @@ -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, collections: List, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/impl/ExportManagerImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/impl/ExportManagerImpl.kt new file mode 100644 index 0000000..8732d7f --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/impl/ExportManagerImpl.kt @@ -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, + ) + + private val sink = + MutableStateFlow(persistentMapOf()) + + 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 = 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 { + 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 { + 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.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() + + 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, + val folders: List, + val collections: List, + val organizations: List, + ) + + 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, + 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, + ): 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 }, + ) + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/impl/ExportServiceImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/impl/JsonExportServiceImpl.kt similarity index 98% rename from common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/impl/ExportServiceImpl.kt rename to common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/impl/JsonExportServiceImpl.kt index 8d820e3..c322253 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/impl/ExportServiceImpl.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/impl/JsonExportServiceImpl.kt @@ -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) } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/model/ExportRequest.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/model/ExportRequest.kt new file mode 100644 index 0000000..461000d --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/export/model/ExportRequest.kt @@ -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, +) diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/session/VaultSessionLocker.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/session/VaultSessionLocker.kt new file mode 100644 index 0000000..f1db361 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/session/VaultSessionLocker.kt @@ -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 { + clearVaultSessionJob?.cancel() + clearVaultSessionJob = null + + try { + // suspend forever + suspendCancellableCoroutine { } + } 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() + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/zip/ZipEntry.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/zip/ZipEntry.kt index 455ec25..51838e6 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/zip/ZipEntry.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/zip/ZipEntry.kt @@ -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 + } } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/zip/ZipService.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/zip/ZipService.kt index bd187e1..f2f4b0f 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/zip/ZipService.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/zip/ZipService.kt @@ -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, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/DownloadAttachmentMetadata.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/DownloadAttachmentMetadata.kt new file mode 100644 index 0000000..104f645 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/DownloadAttachmentMetadata.kt @@ -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 diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/ExportAccount.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/ExportAccount.kt index fbedaa8..73ffbc2 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/ExportAccount.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/ExportAccount.kt @@ -6,4 +6,5 @@ import com.artemchep.keyguard.common.model.DFilter interface ExportAccount : ( DFilter, String, + Boolean, ) -> IO diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/DownloadAttachmentImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/DownloadAttachmentImpl.kt index 030901d..947b3df 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/DownloadAttachmentImpl.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/DownloadAttachmentImpl.kt @@ -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, ): IO = requests .map { request -> - request - .foo() + downloadAttachmentMetadata(request) .flatMap(downloadService::download) } .combine(bucket = THREAD_BUCKET_SIZE) .map { Unit } - - private fun DownloadAttachmentRequest.foo(): IO = 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 = 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), - ) - } - } } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/DownloadAttachmentMetadataImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/DownloadAttachmentMetadataImpl.kt new file mode 100644 index 0000000..ea8107e --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/DownloadAttachmentMetadataImpl.kt @@ -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 = request + .foo() + + private fun DownloadAttachmentRequest.foo(): IO = 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 = 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), + ) + } + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/session/usecase/SubDI.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/session/usecase/SubDI.kt index b7572ec..13a7f32 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/session/usecase/SubDI.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/session/usecase/SubDI.kt @@ -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 { DownloadAttachmentImpl2(this) } + bindSingleton { + DownloadAttachmentMetadataImpl2(this) + } + bindSingleton { + ExportAccountImpl(this) + } bindSingleton { GetCanAddAccountImpl(this) } @@ -533,9 +541,6 @@ fun DI.Builder.createSubDi2( bindSingleton { AddFolderImpl(this) } - bindSingleton { - ExportAccountImpl(this) - } bindSingleton { ExportLogsImpl(this) } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportScreen.kt index 428f2d7..b700e50 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportScreen.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportScreen.kt @@ -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, + ) + } } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportState.kt index 3c9d8b0..7155445 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportState.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportState.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.StateFlow data class ExportState( val itemsFlow: StateFlow, + val attachmentsFlow: StateFlow, val filterFlow: StateFlow, val passwordFlow: StateFlow, val contentFlow: StateFlow, @@ -36,4 +37,15 @@ data class ExportState( val count: Int, val onView: (() -> Unit)? = null, ) + + @Immutable + data class Attachments( + val revision: Int, + val list: List, + val size: String? = null, + val count: Int, + val onView: (() -> Unit)? = null, + val enabled: Boolean, + val onToggle: (() -> Unit)? = null, + ) } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportStateProducer.kt index 72073c1..7c56a14 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportStateProducer.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportStateProducer.kt @@ -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 = 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, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/ExportAccountImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/ExportAccountImpl.kt index 71e8777..ed9be9e 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/ExportAccountImpl.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/ExportAccountImpl.kt @@ -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 = 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() } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/ExportLogsImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/ExportLogsImpl.kt index 4e1a8e4..28ca12b 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/ExportLogsImpl.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/ExportLogsImpl.kt @@ -62,7 +62,7 @@ class ExportLogsImpl( entries = listOf( ZipEntry( name = "logs.txt", - stream = { + data = ZipEntry.Data.In { txt.byteInputStream() }, ), diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/DownloadClientDesktop.kt b/common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/DownloadClientDesktop.kt index b09ce1c..2510431 100644 --- a/common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/DownloadClientDesktop.kt +++ b/common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/DownloadClientDesktop.kt @@ -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(), diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/DownloadTaskDesktop.kt b/common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/DownloadTaskDesktop.kt new file mode 100644 index 0000000..cc36c89 --- /dev/null +++ b/common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/DownloadTaskDesktop.kt @@ -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(), + ) +} diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/ExportManagerImpl.kt b/common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/ExportManagerImpl.kt new file mode 100644 index 0000000..6146db7 --- /dev/null +++ b/common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/ExportManagerImpl.kt @@ -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(), + ) +} diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/core/session/FingerprintRepositoryModule.kt b/common/src/desktopMain/kotlin/com/artemchep/keyguard/core/session/FingerprintRepositoryModule.kt index 6e1fc5b..97635ba 100644 --- a/common/src/desktopMain/kotlin/com/artemchep/keyguard/core/session/FingerprintRepositoryModule.kt +++ b/common/src/desktopMain/kotlin/com/artemchep/keyguard/core/session/FingerprintRepositoryModule.kt @@ -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 { + DownloadTaskDesktop( + directDI = this, + ) + } bindSingleton { DownloadManagerDesktop( directDI = this, diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/core/session/usecase/createSubDi.kt b/common/src/desktopMain/kotlin/com/artemchep/keyguard/core/session/usecase/createSubDi.kt index 122a7fb..28e2e30 100644 --- a/common/src/desktopMain/kotlin/com/artemchep/keyguard/core/session/usecase/createSubDi.kt +++ b/common/src/desktopMain/kotlin/com/artemchep/keyguard/core/session/usecase/createSubDi.kt @@ -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 { QueueSyncByIdImpl(this) } + bindSingleton { + ExportManagerImpl( + directDI = this, + ) + } bindSingleton { NotificationsImpl(this) diff --git a/common/src/jvmMain/kotlin/com/artemchep/keyguard/copy/ZipServiceJvm.kt b/common/src/jvmMain/kotlin/com/artemchep/keyguard/copy/ZipServiceJvm.kt index 87bb5c6..5740dac 100644 --- a/common/src/jvmMain/kotlin/com/artemchep/keyguard/copy/ZipServiceJvm.kt +++ b/common/src/jvmMain/kotlin/com/artemchep/keyguard/copy/ZipServiceJvm.kt @@ -17,7 +17,7 @@ class ZipServiceJvm( directDI: DirectDI, ) : this() - override fun zip( + override suspend fun zip( outputStream: OutputStream, config: ZipConfig, entries: List, @@ -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() diff --git a/common/src/jvmMain/kotlin/com/artemchep/keyguard/copy/download/DownloadClientJvm.kt b/common/src/jvmMain/kotlin/com/artemchep/keyguard/copy/download/DownloadClientJvm.kt index 5c7b865..f7040cf 100644 --- a/common/src/jvmMain/kotlin/com/artemchep/keyguard/copy/download/DownloadClientJvm.kt +++ b/common/src/jvmMain/kotlin/com/artemchep/keyguard/copy/download/DownloadClientJvm.kt @@ -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 = flow { val exists = file.exists() @@ -227,14 +228,32 @@ abstract class DownloadClientJvm( file.parentFile?.mkdirs() val f = channelFlow { + 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() diff --git a/common/src/jvmMain/kotlin/com/artemchep/keyguard/copy/download/DownloadTaskJvm.kt b/common/src/jvmMain/kotlin/com/artemchep/keyguard/copy/download/DownloadTaskJvm.kt new file mode 100644 index 0000000..b27daba --- /dev/null +++ b/common/src/jvmMain/kotlin/com/artemchep/keyguard/copy/download/DownloadTaskJvm.kt @@ -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 = flow { + val f = channelFlow { + // 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.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, + ) + } +} diff --git a/common/src/jvmMain/kotlin/com/artemchep/keyguard/di/GlobalModuleJvm.kt b/common/src/jvmMain/kotlin/com/artemchep/keyguard/di/GlobalModuleJvm.kt index 650fb1f..1d7099c 100644 --- a/common/src/jvmMain/kotlin/com/artemchep/keyguard/di/GlobalModuleJvm.kt +++ b/common/src/jvmMain/kotlin/com/artemchep/keyguard/di/GlobalModuleJvm.kt @@ -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( + directDI = this, + ) + } bindSingleton { PutVaultSessionImpl( directDI = this, @@ -1169,8 +1175,8 @@ fun globalModuleJvm() = DI.Module( directDI = this, ) } - bindSingleton { - ExportServiceImpl( + bindSingleton { + JsonExportServiceImpl( directDI = this, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/artemchep/keyguard/Main.kt b/desktopApp/src/jvmMain/kotlin/com/artemchep/keyguard/Main.kt index 6bc77fa..f9e7561 100644 --- a/desktopApp/src/jvmMain/kotlin/com/artemchep/keyguard/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/artemchep/keyguard/Main.kt @@ -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 { } - } 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)