feature: Initial Export with attachments
This commit is contained in:
parent
64c0b37558
commit
1d55080796
|
@ -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
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>(),
|
||||||
|
)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.artemchep.keyguard.common.service.download
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
fun interface CacheDirProvider {
|
||||||
|
suspend fun get(): 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>
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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>,
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
|
@ -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,
|
||||||
|
)
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>,
|
||||||
|
|
|
@ -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>
|
|
@ -6,4 +6,5 @@ import com.artemchep.keyguard.common.model.DFilter
|
||||||
interface ExportAccount : (
|
interface ExportAccount : (
|
||||||
DFilter,
|
DFilter,
|
||||||
String,
|
String,
|
||||||
|
Boolean,
|
||||||
) -> IO<Unit>
|
) -> IO<Unit>
|
||||||
|
|
|
@ -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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue