feature: Initial Export with attachments

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -916,9 +916,12 @@
<string name="exportaccount_header_title">Export items</string> <string name="exportaccount_header_title">Export items</string>
<string name="exportaccount_password_label">Archive password</string> <string name="exportaccount_password_label">Archive password</string>
<string name="exportaccount_no_attachments_note">Only vault item information will be exported and will not include associated attachments.</string> <string name="exportaccount_attachments_note">Vault will be kept unlocked during the export process. The attachments are referenced in the vault's data. The attachments are never stored in an unencrypted form in the process.</string>
<string name="exportaccount_include_attachments_title">Export attachments</string>
<string name="exportaccount_export_button">Export</string> <string name="exportaccount_export_button">Export</string>
<string name="exportaccount_export_started">Export started</string>
<string name="exportaccount_export_success">Export complete</string> <string name="exportaccount_export_success">Export complete</string>
<string name="exportaccount_export_failure">Export failed</string>
<string name="contactus_header_title">Contact us</string> <string name="contactus_header_title">Contact us</string>
<string name="contactus_message_label">Your message</string> <string name="contactus_message_label">Your message</string>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import com.artemchep.keyguard.common.model.DCollection
import com.artemchep.keyguard.common.model.DFolder import com.artemchep.keyguard.common.model.DFolder
import com.artemchep.keyguard.common.model.DOrganization import com.artemchep.keyguard.common.model.DOrganization
import com.artemchep.keyguard.common.model.DSecret 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.CollectionExportEntity
import com.artemchep.keyguard.common.service.export.entity.ItemFieldExportEntity import com.artemchep.keyguard.common.service.export.entity.ItemFieldExportEntity
import com.artemchep.keyguard.common.service.export.entity.FolderExportEntity import com.artemchep.keyguard.common.service.export.entity.FolderExportEntity
@ -36,9 +36,9 @@ import kotlinx.serialization.json.putJsonArray
import org.kodein.di.DirectDI import org.kodein.di.DirectDI
import org.kodein.di.instance import org.kodein.di.instance
class ExportServiceImpl( class JsonExportServiceImpl(
private val json: Json, private val json: Json,
) : ExportService { ) : JsonExportService {
constructor( constructor(
directDI: DirectDI, directDI: DirectDI,
) : this( ) : this(
@ -156,7 +156,7 @@ class ExportServiceImpl(
putJsonArray(key) { putJsonArray(key) {
remoteAttachments.forEach { attachment -> remoteAttachments.forEach { attachment ->
val obj = buildJsonObject { val obj = buildJsonObject {
put("id", attachment.remoteCipherId) put("id", attachment.id)
put("size", attachment.size) put("size", attachment.size)
put("fileName", attachment.fileName) put("fileName", attachment.fileName)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,10 +4,14 @@ import androidx.compose.runtime.Composable
import arrow.core.identity import arrow.core.identity
import arrow.core.partially1 import arrow.core.partially1
import com.artemchep.keyguard.common.io.effectTap import com.artemchep.keyguard.common.io.effectTap
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.io.launchIn import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.model.DFilter import com.artemchep.keyguard.common.model.DFilter
import com.artemchep.keyguard.common.model.Loadable import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.model.ToastMessage 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.Permission
import com.artemchep.keyguard.common.service.permission.PermissionService import com.artemchep.keyguard.common.service.permission.PermissionService
import com.artemchep.keyguard.common.service.permission.PermissionState 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.TextFieldModel2
import com.artemchep.keyguard.feature.auth.common.Validated import com.artemchep.keyguard.feature.auth.common.Validated
import com.artemchep.keyguard.feature.auth.common.util.validatedPassword 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.VaultRoute
import com.artemchep.keyguard.feature.home.vault.screen.FilterParams import com.artemchep.keyguard.feature.home.vault.screen.FilterParams
import com.artemchep.keyguard.feature.home.vault.screen.ah import com.artemchep.keyguard.feature.home.vault.screen.ah
@ -58,7 +63,7 @@ fun produceExportScreenState(
getCollections = instance(), getCollections = instance(),
getOrganizations = instance(), getOrganizations = instance(),
permissionService = instance(), permissionService = instance(),
exportAccount = instance(), exportManager = instance(),
) )
} }
@ -78,11 +83,15 @@ fun produceExportScreenState(
getCollections: GetCollections, getCollections: GetCollections,
getOrganizations: GetOrganizations, getOrganizations: GetOrganizations,
permissionService: PermissionService, permissionService: PermissionService,
exportAccount: ExportAccount, exportManager: ExportManager,
): Loadable<ExportState> = produceScreenState( ): Loadable<ExportState> = produceScreenState(
key = "export", key = "export",
initial = Loadable.Loading, initial = Loadable.Loading,
) { ) {
val attachmentsSink = mutablePersistedFlow(
key = "attachments",
) { false }
val passwordSink = mutablePersistedFlow( val passwordSink = mutablePersistedFlow(
key = "password", key = "password",
) { "" } ) { "" }
@ -91,15 +100,20 @@ fun produceExportScreenState(
fun onExport( fun onExport(
password: String, password: String,
filter: DFilter, filter: DFilter,
attachments: Boolean,
) { ) {
exportAccount( val request = ExportRequest(
filter, filter = filter,
password, password = password,
attachments = attachments,
) )
ioEffect {
exportManager.queue(request)
}
.effectTap { .effectTap {
val msg = ToastMessage( val msg = ToastMessage(
title = translate(Res.string.exportaccount_export_success), title = translate(Res.string.exportaccount_export_started),
type = ToastMessage.Type.SUCCESS, type = ToastMessage.Type.INFO,
) )
message(msg) message(msg)
@ -223,6 +237,54 @@ fun produceExportScreenState(
) )
} }
.stateIn(screenScope) .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 val passwordRawFlow = passwordSink
.validatedPassword( .validatedPassword(
scope = this, scope = this,
@ -242,10 +304,11 @@ fun produceExportScreenState(
.stateIn(screenScope) .stateIn(screenScope)
val contentFlow = combine( val contentFlow = combine(
writeDownloadsPermissionFlow, writeDownloadsPermissionFlow,
attachmentsSink,
passwordRawFlow, passwordRawFlow,
filterResult filterResult
.filterFlow, .filterFlow,
) { writeDownloadsPermission, passwordValidated, filterHolder -> ) { writeDownloadsPermission, enableAttachments, passwordValidated, filterHolder ->
val export = kotlin.run { val export = kotlin.run {
val canExport = passwordValidated is Validated.Success && val canExport = passwordValidated is Validated.Success &&
writeDownloadsPermission is PermissionState.Granted writeDownloadsPermission is PermissionState.Granted
@ -263,6 +326,7 @@ fun produceExportScreenState(
::onExport ::onExport
.partially1(passwordValidated.model) .partially1(passwordValidated.model)
.partially1(filter) .partially1(filter)
.partially1(enableAttachments)
} else { } else {
null null
} }
@ -276,6 +340,7 @@ fun produceExportScreenState(
val state = ExportState( val state = ExportState(
itemsFlow = itemsFlow, itemsFlow = itemsFlow,
attachmentsFlow = attachmentsFlow,
filterFlow = filterFlow, filterFlow = filterFlow,
passwordFlow = passwordFlow, passwordFlow = passwordFlow,
contentFlow = contentFlow, contentFlow = contentFlow,

View File

@ -4,18 +4,24 @@ import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.bind import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.ioEffect import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.model.DFilter 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.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.ZipConfig
import com.artemchep.keyguard.common.service.zip.ZipEntry import com.artemchep.keyguard.common.service.zip.ZipEntry
import com.artemchep.keyguard.common.service.zip.ZipService import com.artemchep.keyguard.common.service.zip.ZipService
import com.artemchep.keyguard.common.usecase.DateFormatter 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.ExportAccount
import com.artemchep.keyguard.common.usecase.GetCiphers import com.artemchep.keyguard.common.usecase.GetCiphers
import com.artemchep.keyguard.common.usecase.GetCollections import com.artemchep.keyguard.common.usecase.GetCollections
import com.artemchep.keyguard.common.usecase.GetFolders import com.artemchep.keyguard.common.usecase.GetFolders
import com.artemchep.keyguard.common.usecase.GetOrganizations import com.artemchep.keyguard.common.usecase.GetOrganizations
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import org.kodein.di.DirectDI import org.kodein.di.DirectDI
@ -26,7 +32,7 @@ import org.kodein.di.instance
*/ */
class ExportAccountImpl( class ExportAccountImpl(
private val directDI: DirectDI, private val directDI: DirectDI,
private val exportService: ExportService, private val jsonExportService: JsonExportService,
private val dirsService: DirsService, private val dirsService: DirsService,
private val zipService: ZipService, private val zipService: ZipService,
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
@ -34,6 +40,8 @@ class ExportAccountImpl(
private val getCollections: GetCollections, private val getCollections: GetCollections,
private val getFolders: GetFolders, private val getFolders: GetFolders,
private val getCiphers: GetCiphers, private val getCiphers: GetCiphers,
private val downloadTask: DownloadTask,
private val downloadAttachmentMetadata: DownloadAttachmentMetadata,
) : ExportAccount { ) : ExportAccount {
companion object { companion object {
private const val TAG = "ExportAccount.bitwarden" private const val TAG = "ExportAccount.bitwarden"
@ -41,7 +49,7 @@ class ExportAccountImpl(
constructor(directDI: DirectDI) : this( constructor(directDI: DirectDI) : this(
directDI = directDI, directDI = directDI,
exportService = directDI.instance(), jsonExportService = directDI.instance(),
dirsService = directDI.instance(), dirsService = directDI.instance(),
zipService = directDI.instance(), zipService = directDI.instance(),
dateFormatter = directDI.instance(), dateFormatter = directDI.instance(),
@ -49,11 +57,14 @@ class ExportAccountImpl(
getCollections = directDI.instance(), getCollections = directDI.instance(),
getFolders = directDI.instance(), getFolders = directDI.instance(),
getCiphers = directDI.instance(), getCiphers = directDI.instance(),
downloadTask = directDI.instance(),
downloadAttachmentMetadata = directDI.instance(),
) )
override fun invoke( override fun invoke(
filter: DFilter, filter: DFilter,
password: String, password: String,
attachments: Boolean,
): IO<Unit> = ioEffect { ): IO<Unit> = ioEffect {
val ciphers = getCiphersByFilter(filter) val ciphers = getCiphersByFilter(filter)
val folders = kotlin.run { val folders = kotlin.run {
@ -95,21 +106,52 @@ class ExportAccountImpl(
// Map vault data to the JSON export // Map vault data to the JSON export
// in the target type. // in the target type.
val json = exportService.export( val json = jsonExportService.export(
organizations = organizations, organizations = organizations,
collections = collections, collections = collections,
folders = folders, folders = folders,
ciphers = ciphers, ciphers = ciphers,
) )
// val zipParams =
val fileName = kotlin.run { val fileName = kotlin.run {
val now = Clock.System.now() val now = Clock.System.now()
val dt = dateFormatter.formatDateTimeMachine(now) val dt = dateFormatter.formatDateTimeMachine(now)
"keyguard_export_$dt.zip" "keyguard_export_$dt.zip"
} }
dirsService.saveToDownloads(fileName) { os -> 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( zipService.zip(
outputStream = os, outputStream = os,
config = ZipConfig( config = ZipConfig(
@ -117,14 +159,7 @@ class ExportAccountImpl(
password = password, password = password,
), ),
), ),
entries = listOf( entries = entries,
ZipEntry(
name = "vault.json",
stream = {
json.byteInputStream()
},
),
),
) )
}.bind() }.bind()
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,8 +12,8 @@ import com.artemchep.keyguard.common.service.download.DownloadService
import com.artemchep.keyguard.common.service.download.DownloadServiceImpl import com.artemchep.keyguard.common.service.download.DownloadServiceImpl
import com.artemchep.keyguard.common.service.execute.ExecuteCommand import com.artemchep.keyguard.common.service.execute.ExecuteCommand
import com.artemchep.keyguard.common.service.execute.impl.ExecuteCommandImpl import com.artemchep.keyguard.common.service.execute.impl.ExecuteCommandImpl
import com.artemchep.keyguard.common.service.export.ExportService import com.artemchep.keyguard.common.service.export.JsonExportService
import com.artemchep.keyguard.common.service.export.impl.ExportServiceImpl 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.LinkInfoExtractorExecute
import com.artemchep.keyguard.common.service.extract.impl.LinkInfoPlatformExtractor import com.artemchep.keyguard.common.service.extract.impl.LinkInfoPlatformExtractor
import com.artemchep.keyguard.common.service.gpmprivapps.PrivilegedAppsService 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.relays.di.emailRelayDiModule
import com.artemchep.keyguard.common.service.review.ReviewLog import com.artemchep.keyguard.common.service.review.ReviewLog
import com.artemchep.keyguard.common.service.review.impl.ReviewLogImpl 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.SettingsReadRepository
import com.artemchep.keyguard.common.service.settings.SettingsReadWriteRepository import com.artemchep.keyguard.common.service.settings.SettingsReadWriteRepository
import com.artemchep.keyguard.common.service.settings.impl.SettingsRepositoryImpl import com.artemchep.keyguard.common.service.settings.impl.SettingsRepositoryImpl
@ -601,6 +602,11 @@ fun globalModuleJvm() = DI.Module(
directDI = this, directDI = this,
) )
} }
bindSingleton<VaultSessionLocker> {
VaultSessionLocker(
directDI = this,
)
}
bindSingleton<PutVaultSession> { bindSingleton<PutVaultSession> {
PutVaultSessionImpl( PutVaultSessionImpl(
directDI = this, directDI = this,
@ -1169,8 +1175,8 @@ fun globalModuleJvm() = DI.Module(
directDI = this, directDI = this,
) )
} }
bindSingleton<ExportService> { bindSingleton<JsonExportService> {
ExportServiceImpl( JsonExportServiceImpl(
directDI = this, directDI = this,
) )
} }

View File

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