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.model.PersistedSession
|
||||
import com.artemchep.keyguard.common.service.filter.GetCipherFilters
|
||||
import com.artemchep.keyguard.common.service.session.VaultSessionLocker
|
||||
import com.artemchep.keyguard.common.worker.Wrker
|
||||
import com.artemchep.keyguard.feature.favicon.Favicon
|
||||
import com.artemchep.keyguard.feature.localization.textResource
|
||||
|
@ -159,35 +160,9 @@ class Main : BaseApp(), DIAware {
|
|||
}
|
||||
|
||||
// timeout
|
||||
var timeoutJob: Job? = null
|
||||
val getVaultLockAfterTimeout: GetVaultLockAfterTimeout by instance()
|
||||
val vaultSessionLocker: VaultSessionLocker by instance()
|
||||
ProcessLifecycleOwner.get().bindBlock {
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
|
||||
try {
|
||||
// suspend forever
|
||||
suspendCancellableCoroutine<Unit> { }
|
||||
} finally {
|
||||
timeoutJob = getVaultLockAfterTimeout()
|
||||
.toIO()
|
||||
// Wait for the timeout duration.
|
||||
.effectMap { duration ->
|
||||
delay(duration)
|
||||
duration
|
||||
}
|
||||
.effectMap {
|
||||
// Clear the current session.
|
||||
val context = LeContext(this)
|
||||
val session = MasterSession.Empty(
|
||||
reason = textResource(Res.string.lock_reason_inactivity, context),
|
||||
)
|
||||
putVaultSession(session)
|
||||
}
|
||||
.flatten()
|
||||
.attempt()
|
||||
.launchIn(GlobalScope)
|
||||
}
|
||||
vaultSessionLocker.keepAlive()
|
||||
}
|
||||
|
||||
// screen lock
|
||||
|
|
|
@ -168,6 +168,14 @@
|
|||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name="com.artemchep.keyguard.android.downloader.receiver.VaultExportActionReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.ACTION_VAULT_EXPORT_CANCEL" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name="com.artemchep.keyguard.android.downloader.receiver.CopyActionReceiver"
|
||||
android:exported="false">
|
||||
|
|
|
@ -9,4 +9,8 @@ object Notifications {
|
|||
)
|
||||
val uploads = NotificationIdPool.sequential(20000)
|
||||
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.content.Context
|
||||
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
|
||||
import com.artemchep.keyguard.common.service.crypto.FileEncryptor
|
||||
import com.artemchep.keyguard.common.usecase.WindowCoroutineScope
|
||||
import com.artemchep.keyguard.copy.download.DownloadClientJvm
|
||||
|
@ -11,6 +12,7 @@ import org.kodein.di.instance
|
|||
|
||||
class DownloadClientAndroid(
|
||||
private val context: Context,
|
||||
cryptoGenerator: CryptoGenerator,
|
||||
windowCoroutineScope: WindowCoroutineScope,
|
||||
okHttpClient: OkHttpClient,
|
||||
fileEncryptor: FileEncryptor,
|
||||
|
@ -18,6 +20,7 @@ class DownloadClientAndroid(
|
|||
cacheDirProvider = {
|
||||
context.cacheDir
|
||||
},
|
||||
cryptoGenerator = cryptoGenerator,
|
||||
windowCoroutineScope = windowCoroutineScope,
|
||||
okHttpClient = okHttpClient,
|
||||
fileEncryptor = fileEncryptor,
|
||||
|
@ -26,6 +29,7 @@ class DownloadClientAndroid(
|
|||
directDI: DirectDI,
|
||||
) : this(
|
||||
context = directDI.instance<Application>(),
|
||||
cryptoGenerator = directDI.instance(),
|
||||
windowCoroutineScope = directDI.instance(),
|
||||
okHttpClient = directDI.instance(),
|
||||
fileEncryptor = directDI.instance(),
|
||||
|
|
|
@ -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,
|
||||
): ForegroundInfo {
|
||||
val notification = kotlin.run {
|
||||
val channelId = createAttachmentDownloadChannel()
|
||||
val channelId = createNotificationChannel()
|
||||
|
||||
// Progress
|
||||
val progressMax = PROGRESS_MAX
|
||||
|
@ -296,7 +296,7 @@ class AttachmentDownloadWorker(
|
|||
): ForegroundInfo {
|
||||
val notification = kotlin.run {
|
||||
val title = applicationContext.getString(R.string.notification_attachment_download_title)
|
||||
val channelId = createAttachmentDownloadChannel()
|
||||
val channelId = createNotificationChannel()
|
||||
NotificationCompat.Builder(applicationContext, channelId)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
|
||||
.setContentTitle(title)
|
||||
|
@ -313,7 +313,7 @@ class AttachmentDownloadWorker(
|
|||
)
|
||||
}
|
||||
|
||||
private fun createAttachmentDownloadChannel(): String {
|
||||
private fun createNotificationChannel(): String {
|
||||
val channel = kotlin.run {
|
||||
val id =
|
||||
applicationContext.getString(R.string.notification_attachment_download_channel_id)
|
||||
|
|
|
@ -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
|
||||
// before writing the file.
|
||||
file.parentFile?.mkdirs()
|
||||
file.outputStream()
|
||||
.use {
|
||||
write(it)
|
||||
}
|
||||
try {
|
||||
file.outputStream()
|
||||
.use {
|
||||
write(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
file.delete()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
|
@ -112,8 +117,8 @@ class DirsServiceAndroid(
|
|||
values.put(MediaStore.MediaColumns.IS_PENDING, false)
|
||||
contentResolver.update(fileUri, values, null, null)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
contentResolver.delete(fileUri, null, null)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,8 @@ import android.content.Context
|
|||
import android.content.pm.PackageManager
|
||||
import com.artemchep.keyguard.android.downloader.DownloadClientAndroid
|
||||
import com.artemchep.keyguard.android.downloader.DownloadManagerImpl
|
||||
import com.artemchep.keyguard.android.downloader.DownloadTaskAndroid
|
||||
import com.artemchep.keyguard.android.downloader.ExportManagerImpl
|
||||
import com.artemchep.keyguard.android.downloader.journal.DownloadRepository
|
||||
import com.artemchep.keyguard.android.downloader.journal.DownloadRepositoryImpl
|
||||
import com.artemchep.keyguard.android.downloader.journal.room.DownloadDatabaseManager
|
||||
|
@ -14,6 +16,8 @@ import com.artemchep.keyguard.common.service.clipboard.ClipboardService
|
|||
import com.artemchep.keyguard.common.service.connectivity.ConnectivityService
|
||||
import com.artemchep.keyguard.common.service.dirs.DirsService
|
||||
import com.artemchep.keyguard.common.service.download.DownloadManager
|
||||
import com.artemchep.keyguard.common.service.download.DownloadTask
|
||||
import com.artemchep.keyguard.common.service.export.ExportManager
|
||||
import com.artemchep.keyguard.common.service.keyvalue.KeyValueStore
|
||||
import com.artemchep.keyguard.common.service.logging.LogRepository
|
||||
import com.artemchep.keyguard.common.service.permission.PermissionService
|
||||
|
@ -55,6 +59,7 @@ import com.artemchep.keyguard.copy.SharedPreferencesStoreFactory
|
|||
import com.artemchep.keyguard.copy.SharedPreferencesStoreFactoryDefault
|
||||
import com.artemchep.keyguard.copy.SubscriptionServiceAndroid
|
||||
import com.artemchep.keyguard.copy.TextServiceAndroid
|
||||
import com.artemchep.keyguard.copy.download.DownloadTaskJvm
|
||||
import com.artemchep.keyguard.core.session.usecase.BiometricStatusUseCaseImpl
|
||||
import com.artemchep.keyguard.core.session.usecase.GetLocaleAndroid
|
||||
import com.artemchep.keyguard.core.session.usecase.PutLocaleAndroid
|
||||
|
@ -185,6 +190,11 @@ fun diFingerprintRepositoryModule() = DI.Module(
|
|||
directDI = this,
|
||||
)
|
||||
}
|
||||
bindSingleton<DownloadTask> {
|
||||
DownloadTaskAndroid(
|
||||
directDI = this,
|
||||
)
|
||||
}
|
||||
bindSingleton<DownloadManager> {
|
||||
DownloadManagerImpl(
|
||||
directDI = this,
|
||||
|
|
|
@ -7,6 +7,7 @@ import androidx.sqlite.db.SupportSQLiteOpenHelper
|
|||
import app.cash.sqldelight.db.AfterVersion
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
||||
import com.artemchep.keyguard.android.downloader.ExportManagerImpl
|
||||
import com.artemchep.keyguard.android.downloader.journal.room.DownloadDatabaseManager
|
||||
import com.artemchep.keyguard.common.NotificationsWorker
|
||||
import com.artemchep.keyguard.common.io.IO
|
||||
|
@ -14,6 +15,7 @@ import com.artemchep.keyguard.common.io.bind
|
|||
import com.artemchep.keyguard.common.io.flatMap
|
||||
import com.artemchep.keyguard.common.io.ioEffect
|
||||
import com.artemchep.keyguard.common.model.MasterKey
|
||||
import com.artemchep.keyguard.common.service.export.ExportManager
|
||||
import com.artemchep.keyguard.common.usecase.QueueSyncAll
|
||||
import com.artemchep.keyguard.common.usecase.QueueSyncById
|
||||
import com.artemchep.keyguard.copy.QueueSyncAllAndroid
|
||||
|
@ -44,6 +46,11 @@ actual fun DI.Builder.createSubDi(
|
|||
bindSingleton<QueueSyncById> {
|
||||
QueueSyncByIdAndroid(this)
|
||||
}
|
||||
bindSingleton<ExportManager> {
|
||||
ExportManagerImpl(
|
||||
directDI = this,
|
||||
)
|
||||
}
|
||||
|
||||
bindSingleton<NotificationsWorker> {
|
||||
NotificationsImpl(this)
|
||||
|
|
|
@ -1,19 +1,23 @@
|
|||
<resources>
|
||||
<string name="app_name">Keyguard</string>
|
||||
|
||||
<string name="notification_attachment_upload_channel_id">com.artemchep.keyguard.UPLOAD_ATTACHMENTS</string>
|
||||
<string name="notification_attachment_upload_channel_id" translatable="false">com.artemchep.keyguard.UPLOAD_ATTACHMENTS</string>
|
||||
<string name="notification_attachment_upload_channel_name">Uploading attachments</string>
|
||||
<string name="notification_attachment_upload_title">Uploading attachments</string>
|
||||
<string name="notification_attachment_upload_content">Uploading attachments</string>
|
||||
|
||||
<string name="notification_attachment_download_channel_id">com.artemchep.keyguard.DOWNLOAD_ATTACHMENTS</string>
|
||||
<string name="notification_attachment_download_channel_id" translatable="false">com.artemchep.keyguard.DOWNLOAD_ATTACHMENTS</string>
|
||||
<string name="notification_attachment_download_channel_name">Download attachments</string>
|
||||
<string name="notification_attachment_download_title">Downloading attachments</string>
|
||||
|
||||
<string name="notification_clipboard_channel_id">com.artemchep.keyguard.CLIPBOARD</string>
|
||||
<string name="notification_vault_export_channel_id" translatable="false">com.artemchep.keyguard.EXPORT_VAULT</string>
|
||||
<string name="notification_vault_export_channel_name">Export</string>
|
||||
<string name="notification_vault_export_title">Exporting vault</string>
|
||||
|
||||
<string name="notification_clipboard_channel_id" translatable="false">com.artemchep.keyguard.CLIPBOARD</string>
|
||||
<string name="notification_clipboard_channel_name">Clipboard</string>
|
||||
|
||||
<string name="notification_sync_vault_channel_id">com.artemchep.keyguard.SYNC_VAULT</string>
|
||||
<string name="notification_sync_vault_channel_id" translatable="false">com.artemchep.keyguard.SYNC_VAULT</string>
|
||||
<string name="notification_sync_vault_channel_name">Sync vault</string>
|
||||
<string name="notification_sync_vault_title">Syncing vault</string>
|
||||
|
||||
|
|
|
@ -916,9 +916,12 @@
|
|||
|
||||
<string name="exportaccount_header_title">Export items</string>
|
||||
<string name="exportaccount_password_label">Archive password</string>
|
||||
<string name="exportaccount_no_attachments_note">Only vault item information will be exported and will not include associated attachments.</string>
|
||||
<string name="exportaccount_attachments_note">Vault will be kept unlocked during the export process. The attachments are referenced in the vault's data. The attachments are never stored in an unencrypted form in the process.</string>
|
||||
<string name="exportaccount_include_attachments_title">Export attachments</string>
|
||||
<string name="exportaccount_export_button">Export</string>
|
||||
<string name="exportaccount_export_started">Export started</string>
|
||||
<string name="exportaccount_export_success">Export complete</string>
|
||||
<string name="exportaccount_export_failure">Export failed</string>
|
||||
|
||||
<string name="contactus_header_title">Contact us</string>
|
||||
<string name="contactus_message_label">Your message</string>
|
||||
|
|
|
@ -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.DSecret
|
||||
|
||||
interface ExportService {
|
||||
interface JsonExportService {
|
||||
/**
|
||||
* Exports given content into an extended Bitwarden
|
||||
* JSON export format.
|
||||
*/
|
||||
fun export(
|
||||
organizations: List<DOrganization>,
|
||||
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.DOrganization
|
||||
import com.artemchep.keyguard.common.model.DSecret
|
||||
import com.artemchep.keyguard.common.service.export.ExportService
|
||||
import com.artemchep.keyguard.common.service.export.JsonExportService
|
||||
import com.artemchep.keyguard.common.service.export.entity.CollectionExportEntity
|
||||
import com.artemchep.keyguard.common.service.export.entity.ItemFieldExportEntity
|
||||
import com.artemchep.keyguard.common.service.export.entity.FolderExportEntity
|
||||
|
@ -36,9 +36,9 @@ import kotlinx.serialization.json.putJsonArray
|
|||
import org.kodein.di.DirectDI
|
||||
import org.kodein.di.instance
|
||||
|
||||
class ExportServiceImpl(
|
||||
class JsonExportServiceImpl(
|
||||
private val json: Json,
|
||||
) : ExportService {
|
||||
) : JsonExportService {
|
||||
constructor(
|
||||
directDI: DirectDI,
|
||||
) : this(
|
||||
|
@ -156,7 +156,7 @@ class ExportServiceImpl(
|
|||
putJsonArray(key) {
|
||||
remoteAttachments.forEach { attachment ->
|
||||
val obj = buildJsonObject {
|
||||
put("id", attachment.remoteCipherId)
|
||||
put("id", attachment.id)
|
||||
put("size", attachment.size)
|
||||
put("fileName", attachment.fileName)
|
||||
}
|
|
@ -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
|
||||
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
class ZipEntry(
|
||||
val name: String,
|
||||
val stream: () -> InputStream,
|
||||
val data: Data,
|
||||
) {
|
||||
sealed interface Data {
|
||||
data class In(
|
||||
val stream: suspend () -> InputStream,
|
||||
) : Data
|
||||
|
||||
data class Out(
|
||||
val stream: suspend (OutputStream) -> Unit,
|
||||
) : Data
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ package com.artemchep.keyguard.common.service.zip
|
|||
import java.io.OutputStream
|
||||
|
||||
interface ZipService {
|
||||
fun zip(
|
||||
suspend fun zip(
|
||||
outputStream: OutputStream,
|
||||
config: ZipConfig,
|
||||
entries: List<ZipEntry>,
|
||||
|
|
|
@ -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 : (
|
||||
DFilter,
|
||||
String,
|
||||
Boolean,
|
||||
) -> IO<Unit>
|
||||
|
|
|
@ -1,39 +1,13 @@
|
|||
package com.artemchep.keyguard.common.usecase.impl
|
||||
|
||||
import com.artemchep.keyguard.common.io.IO
|
||||
import com.artemchep.keyguard.common.io.bind
|
||||
import com.artemchep.keyguard.common.io.combine
|
||||
import com.artemchep.keyguard.common.io.flatMap
|
||||
import com.artemchep.keyguard.common.io.ioEffect
|
||||
import com.artemchep.keyguard.common.io.map
|
||||
import com.artemchep.keyguard.common.io.toIO
|
||||
import com.artemchep.keyguard.common.model.AccountId
|
||||
import com.artemchep.keyguard.common.model.DownloadAttachmentRequest
|
||||
import com.artemchep.keyguard.common.model.DownloadAttachmentRequestData
|
||||
import com.artemchep.keyguard.common.service.crypto.CipherEncryptor
|
||||
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
|
||||
import com.artemchep.keyguard.common.service.download.DownloadService
|
||||
import com.artemchep.keyguard.common.service.text.Base64Service
|
||||
import com.artemchep.keyguard.common.usecase.DownloadAttachment
|
||||
import com.artemchep.keyguard.core.store.DatabaseManager
|
||||
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
|
||||
import com.artemchep.keyguard.provider.bitwarden.api.builder.api
|
||||
import com.artemchep.keyguard.provider.bitwarden.api.builder.get
|
||||
import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrCta
|
||||
import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrImpl
|
||||
import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrKey
|
||||
import com.artemchep.keyguard.provider.bitwarden.crypto.appendOrganizationToken2
|
||||
import com.artemchep.keyguard.provider.bitwarden.crypto.appendProfileToken2
|
||||
import com.artemchep.keyguard.provider.bitwarden.crypto.appendUserToken
|
||||
import com.artemchep.keyguard.provider.bitwarden.crypto.encrypted
|
||||
import com.artemchep.keyguard.provider.bitwarden.crypto.transform
|
||||
import com.artemchep.keyguard.provider.bitwarden.repository.BitwardenCipherRepository
|
||||
import com.artemchep.keyguard.provider.bitwarden.repository.BitwardenOrganizationRepository
|
||||
import com.artemchep.keyguard.provider.bitwarden.repository.BitwardenProfileRepository
|
||||
import com.artemchep.keyguard.provider.bitwarden.repository.BitwardenTokenRepository
|
||||
import com.artemchep.keyguard.provider.bitwarden.usecase.util.withRefreshableAccessToken
|
||||
import io.ktor.client.HttpClient
|
||||
import kotlinx.serialization.json.Json
|
||||
import com.artemchep.keyguard.common.usecase.DownloadAttachmentMetadata
|
||||
import org.kodein.di.DirectDI
|
||||
import org.kodein.di.instance
|
||||
|
||||
|
@ -41,178 +15,25 @@ import org.kodein.di.instance
|
|||
* @author Artem Chepurnyi
|
||||
*/
|
||||
class DownloadAttachmentImpl2(
|
||||
private val tokenRepository: BitwardenTokenRepository,
|
||||
private val cipherRepository: BitwardenCipherRepository,
|
||||
private val profileRepository: BitwardenProfileRepository,
|
||||
private val organizationRepository: BitwardenOrganizationRepository,
|
||||
private val downloadAttachmentMetadata: DownloadAttachmentMetadata,
|
||||
private val downloadService: DownloadService,
|
||||
private val databaseManager: DatabaseManager,
|
||||
private val cipherEncryptor: CipherEncryptor,
|
||||
private val cryptoGenerator: CryptoGenerator,
|
||||
private val base64Service: Base64Service,
|
||||
private val json: Json,
|
||||
private val httpClient: HttpClient,
|
||||
) : DownloadAttachment {
|
||||
companion object {
|
||||
private const val THREAD_BUCKET_SIZE = 10
|
||||
}
|
||||
|
||||
constructor(directDI: DirectDI) : this(
|
||||
tokenRepository = directDI.instance(),
|
||||
cipherRepository = directDI.instance(),
|
||||
profileRepository = directDI.instance(),
|
||||
organizationRepository = directDI.instance(),
|
||||
downloadAttachmentMetadata = directDI.instance(),
|
||||
downloadService = directDI.instance(),
|
||||
databaseManager = directDI.instance(),
|
||||
cipherEncryptor = directDI.instance(),
|
||||
cryptoGenerator = directDI.instance(),
|
||||
base64Service = directDI.instance(),
|
||||
json = directDI.instance(),
|
||||
httpClient = directDI.instance(),
|
||||
)
|
||||
|
||||
override fun invoke(
|
||||
requests: List<DownloadAttachmentRequest>,
|
||||
): IO<Unit> = requests
|
||||
.map { request ->
|
||||
request
|
||||
.foo()
|
||||
downloadAttachmentMetadata(request)
|
||||
.flatMap(downloadService::download)
|
||||
}
|
||||
.combine(bucket = THREAD_BUCKET_SIZE)
|
||||
.map { Unit }
|
||||
|
||||
private fun DownloadAttachmentRequest.foo(): IO<DownloadAttachmentRequestData> = when (this) {
|
||||
is DownloadAttachmentRequest.ByLocalCipherAttachment -> foo()
|
||||
}
|
||||
|
||||
private fun DownloadAttachmentRequest.ByLocalCipherAttachment.foo() = getLatestAttachmentData(
|
||||
localCipherId = localCipherId,
|
||||
remoteCipherId = remoteCipherId,
|
||||
attachmentId = attachmentId,
|
||||
)
|
||||
.map { data ->
|
||||
DownloadAttachmentRequestData(
|
||||
localCipherId = localCipherId,
|
||||
remoteCipherId = remoteCipherId,
|
||||
attachmentId = attachmentId,
|
||||
// data
|
||||
url = data.url,
|
||||
urlIsOneTime = data.urlIsOneTime,
|
||||
name = data.name,
|
||||
encryptionKey = data.encryptionKey,
|
||||
)
|
||||
}
|
||||
|
||||
private class AttachmentData(
|
||||
val url: String,
|
||||
val urlIsOneTime: Boolean,
|
||||
val name: String,
|
||||
val encryptionKey: ByteArray,
|
||||
)
|
||||
|
||||
private fun getLatestAttachmentData(
|
||||
localCipherId: String,
|
||||
remoteCipherId: String?,
|
||||
attachmentId: String,
|
||||
): IO<AttachmentData> = ioEffect {
|
||||
val cipher = cipherRepository.getById(id = localCipherId).bind()
|
||||
requireNotNull(cipher)
|
||||
requireNotNull(remoteCipherId) // can only get attachment info from remote cipher
|
||||
// Check if actual remote cipher ID matches given
|
||||
// remote cipher ID.
|
||||
require(cipher.service.remote?.id == remoteCipherId)
|
||||
|
||||
val attachment = cipher.attachments
|
||||
.asSequence()
|
||||
.mapNotNull { it as? BitwardenCipher.Attachment.Remote }
|
||||
.firstOrNull { it.id == attachmentId }
|
||||
requireNotNull(attachment)
|
||||
|
||||
val accountId = AccountId(cipher.accountId)
|
||||
val token = tokenRepository.getById(id = accountId).bind()
|
||||
requireNotNull(token)
|
||||
val profile = profileRepository.getById(id = accountId).toIO().bind()
|
||||
requireNotNull(profile)
|
||||
val organizations = organizationRepository.getByAccountId(id = accountId).bind()
|
||||
|
||||
// Build cryptography model.
|
||||
val builder = BitwardenCrImpl(
|
||||
cipherEncryptor = cipherEncryptor,
|
||||
cryptoGenerator = cryptoGenerator,
|
||||
base64Service = base64Service,
|
||||
).apply {
|
||||
// We need user keys to decrypt the
|
||||
// profile key.
|
||||
appendUserToken(
|
||||
encKey = base64Service.decode(token.key.encryptionKeyBase64),
|
||||
macKey = base64Service.decode(token.key.macKeyBase64),
|
||||
)
|
||||
appendProfileToken2(
|
||||
keyData = base64Service.decode(profile.keyBase64),
|
||||
privateKey = base64Service.decode(profile.privateKeyBase64),
|
||||
)
|
||||
|
||||
organizations.forEach { organization ->
|
||||
appendOrganizationToken2(
|
||||
id = organization.organizationId,
|
||||
keyData = base64Service.decode(organization.keyBase64),
|
||||
)
|
||||
}
|
||||
}
|
||||
val cr = builder.build()
|
||||
val envEncryptionType = CipherEncryptor.Type.AesCbc256_HmacSha256_B64
|
||||
val organizationId: String? = cipher.organizationId
|
||||
val env = if (organizationId != null) {
|
||||
val key = BitwardenCrKey.OrganizationToken(organizationId)
|
||||
BitwardenCrCta.BitwardenCrCtaEnv(
|
||||
key = key,
|
||||
encryptionType = envEncryptionType,
|
||||
)
|
||||
} else {
|
||||
val key = BitwardenCrKey.UserToken
|
||||
BitwardenCrCta.BitwardenCrCtaEnv(
|
||||
key = key,
|
||||
encryptionType = envEncryptionType,
|
||||
)
|
||||
}
|
||||
val cta = cr.cta(env, BitwardenCrCta.Mode.DECRYPT)
|
||||
|
||||
//
|
||||
kotlin.runCatching {
|
||||
val entity = withRefreshableAccessToken(
|
||||
base64Service = base64Service,
|
||||
httpClient = httpClient,
|
||||
json = json,
|
||||
db = databaseManager,
|
||||
user = token,
|
||||
) { latestUser ->
|
||||
val accessToken = requireNotNull(latestUser.token?.accessToken)
|
||||
latestUser.env.back().api
|
||||
.ciphers.focus(id = remoteCipherId)
|
||||
.attachments.focus(id = attachmentId)
|
||||
.get(
|
||||
httpClient = httpClient,
|
||||
env = latestUser.env.back(),
|
||||
token = accessToken,
|
||||
)
|
||||
}
|
||||
val model = BitwardenCipher.Attachment
|
||||
.encrypted(attachment = entity)
|
||||
.transform(crypto = cta)
|
||||
AttachmentData(
|
||||
url = requireNotNull(model.url),
|
||||
urlIsOneTime = true,
|
||||
name = model.fileName,
|
||||
encryptionKey = base64Service.decode(model.keyBase64),
|
||||
)
|
||||
}.getOrElse {
|
||||
AttachmentData(
|
||||
url = requireNotNull(attachment.url),
|
||||
urlIsOneTime = false,
|
||||
name = attachment.fileName,
|
||||
encryptionKey = base64Service.decode(attachment.keyBase64),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.CopyCipherById
|
||||
import com.artemchep.keyguard.common.usecase.DownloadAttachment
|
||||
import com.artemchep.keyguard.common.usecase.DownloadAttachmentMetadata
|
||||
import com.artemchep.keyguard.common.usecase.EditWordlist
|
||||
import com.artemchep.keyguard.common.usecase.ExportAccount
|
||||
import com.artemchep.keyguard.common.usecase.ExportLogs
|
||||
|
@ -139,6 +140,7 @@ import com.artemchep.keyguard.common.usecase.impl.AddEmailRelayImpl
|
|||
import com.artemchep.keyguard.common.usecase.impl.AddGeneratorHistoryImpl
|
||||
import com.artemchep.keyguard.common.usecase.impl.AddUrlOverrideImpl
|
||||
import com.artemchep.keyguard.common.usecase.impl.DownloadAttachmentImpl2
|
||||
import com.artemchep.keyguard.common.usecase.impl.DownloadAttachmentMetadataImpl2
|
||||
import com.artemchep.keyguard.common.usecase.impl.EditWordlistImpl
|
||||
import com.artemchep.keyguard.common.usecase.impl.GetAccountStatusImpl
|
||||
import com.artemchep.keyguard.common.usecase.impl.GetBreachesImpl
|
||||
|
@ -276,6 +278,12 @@ fun DI.Builder.createSubDi2(
|
|||
bindSingleton<DownloadAttachment> {
|
||||
DownloadAttachmentImpl2(this)
|
||||
}
|
||||
bindSingleton<DownloadAttachmentMetadata> {
|
||||
DownloadAttachmentMetadataImpl2(this)
|
||||
}
|
||||
bindSingleton<ExportAccount> {
|
||||
ExportAccountImpl(this)
|
||||
}
|
||||
bindSingleton<GetCanAddAccount> {
|
||||
GetCanAddAccountImpl(this)
|
||||
}
|
||||
|
@ -533,9 +541,6 @@ fun DI.Builder.createSubDi2(
|
|||
bindSingleton<AddFolder> {
|
||||
AddFolderImpl(this)
|
||||
}
|
||||
bindSingleton<ExportAccount> {
|
||||
ExportAccountImpl(this)
|
||||
}
|
||||
bindSingleton<ExportLogs> {
|
||||
ExportLogsImpl(this)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.artemchep.keyguard.feature.export
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
|
@ -19,6 +20,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
|
@ -32,6 +34,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.artemchep.keyguard.common.model.Loadable
|
||||
import com.artemchep.keyguard.common.service.permission.PermissionState
|
||||
import com.artemchep.keyguard.feature.home.vault.model.FilterItem
|
||||
|
@ -47,11 +50,14 @@ import com.artemchep.keyguard.ui.DefaultFab
|
|||
import com.artemchep.keyguard.ui.ExpandedIfNotEmpty
|
||||
import com.artemchep.keyguard.ui.FabState
|
||||
import com.artemchep.keyguard.ui.FlatItem
|
||||
import com.artemchep.keyguard.ui.FlatItemLayout
|
||||
import com.artemchep.keyguard.ui.FlatItemTextContent
|
||||
import com.artemchep.keyguard.ui.MediumEmphasisAlpha
|
||||
import com.artemchep.keyguard.ui.OptionsButton
|
||||
import com.artemchep.keyguard.ui.PasswordFlatTextField
|
||||
import com.artemchep.keyguard.ui.ScaffoldColumn
|
||||
import com.artemchep.keyguard.ui.icons.ChevronIcon
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardAttachment
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardCipher
|
||||
import com.artemchep.keyguard.ui.icons.icon
|
||||
import com.artemchep.keyguard.ui.skeleton.SkeletonTextField
|
||||
|
@ -64,6 +70,7 @@ import com.artemchep.keyguard.ui.theme.warningContainer
|
|||
import com.artemchep.keyguard.ui.toolbar.LargeToolbar
|
||||
import com.artemchep.keyguard.ui.toolbar.SmallToolbar
|
||||
import com.artemchep.keyguard.ui.toolbar.util.ToolbarBehavior
|
||||
import com.artemchep.keyguard.ui.util.HorizontalDivider
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
@ -154,6 +161,7 @@ fun ExportScreenOk(
|
|||
state: ExportState,
|
||||
) {
|
||||
val items by state.itemsFlow.collectAsState()
|
||||
val attachments by state.attachmentsFlow.collectAsState()
|
||||
val filter by state.filterFlow.collectAsState()
|
||||
val password by state.passwordFlow.collectAsState()
|
||||
val content by state.contentFlow.collectAsState()
|
||||
|
@ -195,6 +203,7 @@ fun ExportScreenOk(
|
|||
ExportScreen(
|
||||
modifier = modifier,
|
||||
items = items,
|
||||
attachments = attachments,
|
||||
filter = filter,
|
||||
password = password,
|
||||
content = content,
|
||||
|
@ -251,6 +260,7 @@ private fun ExportScreenFilterButton(
|
|||
private fun ExportScreen(
|
||||
modifier: Modifier,
|
||||
items: ExportState.Items? = null,
|
||||
attachments: ExportState.Attachments? = null,
|
||||
filter: ExportState.Filter? = null,
|
||||
password: ExportState.Password? = null,
|
||||
content: ExportState.Content? = null,
|
||||
|
@ -328,39 +338,47 @@ private fun ExportScreen(
|
|||
ExportContentSkeleton()
|
||||
} else if (
|
||||
items != null &&
|
||||
attachments != null &&
|
||||
password != null &&
|
||||
content != null
|
||||
) {
|
||||
ExportContentOk(
|
||||
items = items,
|
||||
attachments = attachments,
|
||||
password = password,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.height(32.dp),
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = Dimens.horizontalPadding),
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = null,
|
||||
tint = LocalContentColor.current.combineAlpha(alpha = MediumEmphasisAlpha),
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.height(16.dp),
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = Dimens.horizontalPadding),
|
||||
text = stringResource(Res.string.exportaccount_no_attachments_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = LocalContentColor.current
|
||||
.combineAlpha(alpha = MediumEmphasisAlpha),
|
||||
)
|
||||
ExpandedIfNotEmpty(
|
||||
Unit.takeIf { attachments?.enabled == true },
|
||||
) {
|
||||
Column {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.height(32.dp),
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = Dimens.horizontalPadding),
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = null,
|
||||
tint = LocalContentColor.current.combineAlpha(alpha = MediumEmphasisAlpha),
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.height(16.dp),
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = Dimens.horizontalPadding),
|
||||
text = stringResource(Res.string.exportaccount_attachments_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = LocalContentColor.current
|
||||
.combineAlpha(alpha = MediumEmphasisAlpha),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -377,6 +395,7 @@ private fun ColumnScope.ExportContentSkeleton(
|
|||
@Composable
|
||||
private fun ColumnScope.ExportContentOk(
|
||||
items: ExportState.Items,
|
||||
attachments: ExportState.Attachments,
|
||||
password: ExportState.Password,
|
||||
content: ExportState.Content,
|
||||
) {
|
||||
|
@ -452,4 +471,45 @@ private fun ColumnScope.ExportContentOk(
|
|||
},
|
||||
onClick = items.onView,
|
||||
)
|
||||
if (attachments.onToggle != null) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
FlatItemLayout(
|
||||
leading = {
|
||||
BadgedBox(
|
||||
modifier = Modifier
|
||||
.zIndex(20f),
|
||||
badge = {
|
||||
val size = attachments.size
|
||||
?: return@BadgedBox
|
||||
Badge(
|
||||
containerColor = MaterialTheme.colorScheme.badgeContainer,
|
||||
) {
|
||||
Text(text = size)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(Icons.Outlined.KeyguardAttachment, null)
|
||||
}
|
||||
},
|
||||
content = {
|
||||
FlatItemTextContent(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.string.exportaccount_include_attachments_title),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = attachments.enabled,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
},
|
||||
onClick = attachments.onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
|
||||
data class ExportState(
|
||||
val itemsFlow: StateFlow<Items>,
|
||||
val attachmentsFlow: StateFlow<Attachments>,
|
||||
val filterFlow: StateFlow<Filter>,
|
||||
val passwordFlow: StateFlow<Password>,
|
||||
val contentFlow: StateFlow<Content>,
|
||||
|
@ -36,4 +37,15 @@ data class ExportState(
|
|||
val count: Int,
|
||||
val onView: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class Attachments(
|
||||
val revision: Int,
|
||||
val list: List<DSecret.Attachment>,
|
||||
val size: String? = null,
|
||||
val count: Int,
|
||||
val onView: (() -> Unit)? = null,
|
||||
val enabled: Boolean,
|
||||
val onToggle: (() -> Unit)? = null,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,10 +4,14 @@ import androidx.compose.runtime.Composable
|
|||
import arrow.core.identity
|
||||
import arrow.core.partially1
|
||||
import com.artemchep.keyguard.common.io.effectTap
|
||||
import com.artemchep.keyguard.common.io.ioEffect
|
||||
import com.artemchep.keyguard.common.io.launchIn
|
||||
import com.artemchep.keyguard.common.model.DFilter
|
||||
import com.artemchep.keyguard.common.model.Loadable
|
||||
import com.artemchep.keyguard.common.model.ToastMessage
|
||||
import com.artemchep.keyguard.common.model.fileSize
|
||||
import com.artemchep.keyguard.common.service.export.ExportManager
|
||||
import com.artemchep.keyguard.common.service.export.model.ExportRequest
|
||||
import com.artemchep.keyguard.common.service.permission.Permission
|
||||
import com.artemchep.keyguard.common.service.permission.PermissionService
|
||||
import com.artemchep.keyguard.common.service.permission.PermissionState
|
||||
|
@ -22,6 +26,7 @@ import com.artemchep.keyguard.common.usecase.filterHiddenProfiles
|
|||
import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
|
||||
import com.artemchep.keyguard.feature.auth.common.Validated
|
||||
import com.artemchep.keyguard.feature.auth.common.util.validatedPassword
|
||||
import com.artemchep.keyguard.feature.filepicker.humanReadableByteCountSI
|
||||
import com.artemchep.keyguard.feature.home.vault.VaultRoute
|
||||
import com.artemchep.keyguard.feature.home.vault.screen.FilterParams
|
||||
import com.artemchep.keyguard.feature.home.vault.screen.ah
|
||||
|
@ -58,7 +63,7 @@ fun produceExportScreenState(
|
|||
getCollections = instance(),
|
||||
getOrganizations = instance(),
|
||||
permissionService = instance(),
|
||||
exportAccount = instance(),
|
||||
exportManager = instance(),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -78,11 +83,15 @@ fun produceExportScreenState(
|
|||
getCollections: GetCollections,
|
||||
getOrganizations: GetOrganizations,
|
||||
permissionService: PermissionService,
|
||||
exportAccount: ExportAccount,
|
||||
exportManager: ExportManager,
|
||||
): Loadable<ExportState> = produceScreenState(
|
||||
key = "export",
|
||||
initial = Loadable.Loading,
|
||||
) {
|
||||
val attachmentsSink = mutablePersistedFlow(
|
||||
key = "attachments",
|
||||
) { false }
|
||||
|
||||
val passwordSink = mutablePersistedFlow(
|
||||
key = "password",
|
||||
) { "" }
|
||||
|
@ -91,15 +100,20 @@ fun produceExportScreenState(
|
|||
fun onExport(
|
||||
password: String,
|
||||
filter: DFilter,
|
||||
attachments: Boolean,
|
||||
) {
|
||||
exportAccount(
|
||||
filter,
|
||||
password,
|
||||
val request = ExportRequest(
|
||||
filter = filter,
|
||||
password = password,
|
||||
attachments = attachments,
|
||||
)
|
||||
ioEffect {
|
||||
exportManager.queue(request)
|
||||
}
|
||||
.effectTap {
|
||||
val msg = ToastMessage(
|
||||
title = translate(Res.string.exportaccount_export_success),
|
||||
type = ToastMessage.Type.SUCCESS,
|
||||
title = translate(Res.string.exportaccount_export_started),
|
||||
type = ToastMessage.Type.INFO,
|
||||
)
|
||||
message(msg)
|
||||
|
||||
|
@ -223,6 +237,54 @@ fun produceExportScreenState(
|
|||
)
|
||||
}
|
||||
.stateIn(screenScope)
|
||||
val attachmentsFlow = filteredCiphersFlow
|
||||
.map { state ->
|
||||
val attachments = state.list
|
||||
.flatMap { it.attachments }
|
||||
val attachmentsTotalSizeByte = attachments.sumOf { it.fileSize() ?: 0L }
|
||||
.takeIf { it > 0L }
|
||||
?.let { humanReadableByteCountSI(it) }
|
||||
ExportState.Attachments(
|
||||
revision = state.filterConfig?.id ?: 0,
|
||||
list = attachments,
|
||||
size = attachmentsTotalSizeByte,
|
||||
count = attachments.size,
|
||||
onView = onClick {
|
||||
val filter = DFilter.And(
|
||||
listOfNotNull(
|
||||
DFilter.ByAttachments,
|
||||
state.filterConfig?.filter,
|
||||
),
|
||||
)
|
||||
val route = VaultRoute(
|
||||
args = VaultRoute.Args(
|
||||
appBar = VaultRoute.Args.AppBar(
|
||||
title = translate(Res.string.exportaccount_header_title),
|
||||
),
|
||||
filter = filter,
|
||||
trash = false,
|
||||
preselect = false,
|
||||
canAddSecrets = false,
|
||||
),
|
||||
)
|
||||
val intent = NavigationIntent.NavigateToRoute(route)
|
||||
navigate(intent)
|
||||
},
|
||||
enabled = false,
|
||||
onToggle = null,
|
||||
)
|
||||
}
|
||||
.combine(attachmentsSink) { state, enableAttachments ->
|
||||
if (state.count == 0) {
|
||||
return@combine state
|
||||
}
|
||||
state.copy(
|
||||
enabled = enableAttachments,
|
||||
onToggle = attachmentsSink::value::set
|
||||
.partially1(!enableAttachments),
|
||||
)
|
||||
}
|
||||
.stateIn(screenScope)
|
||||
val passwordRawFlow = passwordSink
|
||||
.validatedPassword(
|
||||
scope = this,
|
||||
|
@ -242,10 +304,11 @@ fun produceExportScreenState(
|
|||
.stateIn(screenScope)
|
||||
val contentFlow = combine(
|
||||
writeDownloadsPermissionFlow,
|
||||
attachmentsSink,
|
||||
passwordRawFlow,
|
||||
filterResult
|
||||
.filterFlow,
|
||||
) { writeDownloadsPermission, passwordValidated, filterHolder ->
|
||||
) { writeDownloadsPermission, enableAttachments, passwordValidated, filterHolder ->
|
||||
val export = kotlin.run {
|
||||
val canExport = passwordValidated is Validated.Success &&
|
||||
writeDownloadsPermission is PermissionState.Granted
|
||||
|
@ -263,6 +326,7 @@ fun produceExportScreenState(
|
|||
::onExport
|
||||
.partially1(passwordValidated.model)
|
||||
.partially1(filter)
|
||||
.partially1(enableAttachments)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
@ -276,6 +340,7 @@ fun produceExportScreenState(
|
|||
|
||||
val state = ExportState(
|
||||
itemsFlow = itemsFlow,
|
||||
attachmentsFlow = attachmentsFlow,
|
||||
filterFlow = filterFlow,
|
||||
passwordFlow = passwordFlow,
|
||||
contentFlow = contentFlow,
|
||||
|
|
|
@ -4,18 +4,24 @@ import com.artemchep.keyguard.common.io.IO
|
|||
import com.artemchep.keyguard.common.io.bind
|
||||
import com.artemchep.keyguard.common.io.ioEffect
|
||||
import com.artemchep.keyguard.common.model.DFilter
|
||||
import com.artemchep.keyguard.common.model.DownloadAttachmentRequest
|
||||
import com.artemchep.keyguard.common.model.fileName
|
||||
import com.artemchep.keyguard.common.service.dirs.DirsService
|
||||
import com.artemchep.keyguard.common.service.export.ExportService
|
||||
import com.artemchep.keyguard.common.service.download.DownloadTask
|
||||
import com.artemchep.keyguard.common.service.download.DownloadWriter
|
||||
import com.artemchep.keyguard.common.service.export.JsonExportService
|
||||
import com.artemchep.keyguard.common.service.zip.ZipConfig
|
||||
import com.artemchep.keyguard.common.service.zip.ZipEntry
|
||||
import com.artemchep.keyguard.common.service.zip.ZipService
|
||||
import com.artemchep.keyguard.common.usecase.DateFormatter
|
||||
import com.artemchep.keyguard.common.usecase.DownloadAttachmentMetadata
|
||||
import com.artemchep.keyguard.common.usecase.ExportAccount
|
||||
import com.artemchep.keyguard.common.usecase.GetCiphers
|
||||
import com.artemchep.keyguard.common.usecase.GetCollections
|
||||
import com.artemchep.keyguard.common.usecase.GetFolders
|
||||
import com.artemchep.keyguard.common.usecase.GetOrganizations
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.last
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.Clock
|
||||
import org.kodein.di.DirectDI
|
||||
|
@ -26,7 +32,7 @@ import org.kodein.di.instance
|
|||
*/
|
||||
class ExportAccountImpl(
|
||||
private val directDI: DirectDI,
|
||||
private val exportService: ExportService,
|
||||
private val jsonExportService: JsonExportService,
|
||||
private val dirsService: DirsService,
|
||||
private val zipService: ZipService,
|
||||
private val dateFormatter: DateFormatter,
|
||||
|
@ -34,6 +40,8 @@ class ExportAccountImpl(
|
|||
private val getCollections: GetCollections,
|
||||
private val getFolders: GetFolders,
|
||||
private val getCiphers: GetCiphers,
|
||||
private val downloadTask: DownloadTask,
|
||||
private val downloadAttachmentMetadata: DownloadAttachmentMetadata,
|
||||
) : ExportAccount {
|
||||
companion object {
|
||||
private const val TAG = "ExportAccount.bitwarden"
|
||||
|
@ -41,7 +49,7 @@ class ExportAccountImpl(
|
|||
|
||||
constructor(directDI: DirectDI) : this(
|
||||
directDI = directDI,
|
||||
exportService = directDI.instance(),
|
||||
jsonExportService = directDI.instance(),
|
||||
dirsService = directDI.instance(),
|
||||
zipService = directDI.instance(),
|
||||
dateFormatter = directDI.instance(),
|
||||
|
@ -49,11 +57,14 @@ class ExportAccountImpl(
|
|||
getCollections = directDI.instance(),
|
||||
getFolders = directDI.instance(),
|
||||
getCiphers = directDI.instance(),
|
||||
downloadTask = directDI.instance(),
|
||||
downloadAttachmentMetadata = directDI.instance(),
|
||||
)
|
||||
|
||||
override fun invoke(
|
||||
filter: DFilter,
|
||||
password: String,
|
||||
attachments: Boolean,
|
||||
): IO<Unit> = ioEffect {
|
||||
val ciphers = getCiphersByFilter(filter)
|
||||
val folders = kotlin.run {
|
||||
|
@ -95,21 +106,52 @@ class ExportAccountImpl(
|
|||
|
||||
// Map vault data to the JSON export
|
||||
// in the target type.
|
||||
val json = exportService.export(
|
||||
val json = jsonExportService.export(
|
||||
organizations = organizations,
|
||||
collections = collections,
|
||||
folders = folders,
|
||||
ciphers = ciphers,
|
||||
)
|
||||
|
||||
// val zipParams =
|
||||
|
||||
val fileName = kotlin.run {
|
||||
val now = Clock.System.now()
|
||||
val dt = dateFormatter.formatDateTimeMachine(now)
|
||||
"keyguard_export_$dt.zip"
|
||||
}
|
||||
dirsService.saveToDownloads(fileName) { os ->
|
||||
val entriesAttachments = ciphers.flatMap { cipher ->
|
||||
cipher.attachments
|
||||
.map { attachment ->
|
||||
ZipEntry(
|
||||
name = "attachments/${attachment.id}/${attachment.fileName()}",
|
||||
data = ZipEntry.Data.Out {
|
||||
val writer = DownloadWriter.StreamWriter(it)
|
||||
val request = DownloadAttachmentRequest.ByLocalCipherAttachment(
|
||||
localCipherId = cipher.id,
|
||||
remoteCipherId = cipher.service.remote?.id,
|
||||
attachmentId = attachment.id,
|
||||
)
|
||||
val data = downloadAttachmentMetadata(request)
|
||||
.bind()
|
||||
downloadTask.fileLoader(
|
||||
url = data.url,
|
||||
key = data.encryptionKey,
|
||||
writer = writer,
|
||||
).last().also {
|
||||
println(it)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
val entries = listOf(
|
||||
ZipEntry(
|
||||
name = "vault.json",
|
||||
data = ZipEntry.Data.In {
|
||||
json.byteInputStream()
|
||||
},
|
||||
),
|
||||
) + entriesAttachments
|
||||
zipService.zip(
|
||||
outputStream = os,
|
||||
config = ZipConfig(
|
||||
|
@ -117,14 +159,7 @@ class ExportAccountImpl(
|
|||
password = password,
|
||||
),
|
||||
),
|
||||
entries = listOf(
|
||||
ZipEntry(
|
||||
name = "vault.json",
|
||||
stream = {
|
||||
json.byteInputStream()
|
||||
},
|
||||
),
|
||||
),
|
||||
entries = entries,
|
||||
)
|
||||
}.bind()
|
||||
}
|
||||
|
|
|
@ -62,7 +62,7 @@ class ExportLogsImpl(
|
|||
entries = listOf(
|
||||
ZipEntry(
|
||||
name = "logs.txt",
|
||||
stream = {
|
||||
data = ZipEntry.Data.In {
|
||||
txt.byteInputStream()
|
||||
},
|
||||
),
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.artemchep.keyguard.copy
|
||||
|
||||
import com.artemchep.keyguard.common.io.bind
|
||||
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
|
||||
import com.artemchep.keyguard.common.service.crypto.FileEncryptor
|
||||
import com.artemchep.keyguard.common.usecase.WindowCoroutineScope
|
||||
import com.artemchep.keyguard.copy.download.DownloadClientJvm
|
||||
|
@ -11,6 +12,7 @@ import java.io.File
|
|||
|
||||
class DownloadClientDesktop(
|
||||
private val dataDirectory: DataDirectory,
|
||||
cryptoGenerator: CryptoGenerator,
|
||||
windowCoroutineScope: WindowCoroutineScope,
|
||||
okHttpClient: OkHttpClient,
|
||||
fileEncryptor: FileEncryptor,
|
||||
|
@ -20,6 +22,7 @@ class DownloadClientDesktop(
|
|||
.bind()
|
||||
File(path)
|
||||
},
|
||||
cryptoGenerator = cryptoGenerator,
|
||||
windowCoroutineScope = windowCoroutineScope,
|
||||
okHttpClient = okHttpClient,
|
||||
fileEncryptor = fileEncryptor,
|
||||
|
@ -28,6 +31,7 @@ class DownloadClientDesktop(
|
|||
directDI: DirectDI,
|
||||
) : this(
|
||||
dataDirectory = directDI.instance(),
|
||||
cryptoGenerator = directDI.instance(),
|
||||
windowCoroutineScope = directDI.instance(),
|
||||
okHttpClient = directDI.instance(),
|
||||
fileEncryptor = directDI.instance(),
|
||||
|
|
|
@ -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.connectivity.ConnectivityService
|
||||
import com.artemchep.keyguard.common.service.download.DownloadManager
|
||||
import com.artemchep.keyguard.common.service.download.DownloadTask
|
||||
import com.artemchep.keyguard.common.service.export.ExportManager
|
||||
import com.artemchep.keyguard.common.service.keyvalue.KeyValueStore
|
||||
import com.artemchep.keyguard.common.service.keyvalue.impl.FileJsonKeyValueStoreStore
|
||||
import com.artemchep.keyguard.common.service.keyvalue.impl.JsonKeyValueStore
|
||||
|
@ -53,11 +55,14 @@ import com.artemchep.keyguard.copy.DataDirectory
|
|||
import com.artemchep.keyguard.copy.DownloadClientDesktop
|
||||
import com.artemchep.keyguard.copy.DownloadManagerDesktop
|
||||
import com.artemchep.keyguard.copy.DownloadRepositoryDesktop
|
||||
import com.artemchep.keyguard.copy.DownloadTaskDesktop
|
||||
import com.artemchep.keyguard.copy.ExportManagerImpl
|
||||
import com.artemchep.keyguard.copy.GetBarcodeImageJvm
|
||||
import com.artemchep.keyguard.copy.PermissionServiceJvm
|
||||
import com.artemchep.keyguard.copy.PowerServiceJvm
|
||||
import com.artemchep.keyguard.copy.ReviewServiceJvm
|
||||
import com.artemchep.keyguard.copy.TextServiceJvm
|
||||
import com.artemchep.keyguard.copy.download.DownloadTaskJvm
|
||||
import com.artemchep.keyguard.di.globalModuleJvm
|
||||
import com.artemchep.keyguard.platform.LeContext
|
||||
import com.artemchep.keyguard.util.traverse
|
||||
|
@ -272,6 +277,11 @@ fun diFingerprintRepositoryModule() = DI.Module(
|
|||
directDI = this,
|
||||
)
|
||||
}
|
||||
bindSingleton<DownloadTask> {
|
||||
DownloadTaskDesktop(
|
||||
directDI = this,
|
||||
)
|
||||
}
|
||||
bindSingleton<DownloadManager> {
|
||||
DownloadManagerDesktop(
|
||||
directDI = this,
|
||||
|
|
|
@ -6,10 +6,12 @@ import com.artemchep.keyguard.common.io.effectMap
|
|||
import com.artemchep.keyguard.common.io.ioRaise
|
||||
import com.artemchep.keyguard.common.model.DownloadAttachmentRequest
|
||||
import com.artemchep.keyguard.common.model.MasterKey
|
||||
import com.artemchep.keyguard.common.service.export.ExportManager
|
||||
import com.artemchep.keyguard.common.usecase.DownloadAttachment
|
||||
import com.artemchep.keyguard.common.usecase.QueueSyncAll
|
||||
import com.artemchep.keyguard.common.usecase.QueueSyncById
|
||||
import com.artemchep.keyguard.copy.DataDirectory
|
||||
import com.artemchep.keyguard.copy.ExportManagerImpl
|
||||
import com.artemchep.keyguard.core.store.DatabaseManager
|
||||
import com.artemchep.keyguard.core.store.DatabaseManagerImpl
|
||||
import com.artemchep.keyguard.core.store.SqlManagerFile
|
||||
|
@ -32,6 +34,11 @@ actual fun DI.Builder.createSubDi(
|
|||
bindSingleton<QueueSyncById> {
|
||||
QueueSyncByIdImpl(this)
|
||||
}
|
||||
bindSingleton<ExportManager> {
|
||||
ExportManagerImpl(
|
||||
directDI = this,
|
||||
)
|
||||
}
|
||||
|
||||
bindSingleton<NotificationsWorker> {
|
||||
NotificationsImpl(this)
|
||||
|
|
|
@ -17,7 +17,7 @@ class ZipServiceJvm(
|
|||
directDI: DirectDI,
|
||||
) : this()
|
||||
|
||||
override fun zip(
|
||||
override suspend fun zip(
|
||||
outputStream: OutputStream,
|
||||
config: ZipConfig,
|
||||
entries: List<ZipEntry>,
|
||||
|
@ -30,9 +30,14 @@ class ZipServiceJvm(
|
|||
)
|
||||
zipStream.putNextEntry(entryParams)
|
||||
try {
|
||||
val inputStream = entry.stream()
|
||||
inputStream.use {
|
||||
it.copyTo(zipStream)
|
||||
when (val d = entry.data) {
|
||||
is ZipEntry.Data.In -> {
|
||||
d.stream().use { inputStream -> inputStream.copyTo(zipStream) }
|
||||
}
|
||||
|
||||
is ZipEntry.Data.Out -> {
|
||||
d.stream(zipStream)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
zipStream.closeEntry()
|
||||
|
|
|
@ -5,6 +5,7 @@ import arrow.core.right
|
|||
import com.artemchep.keyguard.android.downloader.journal.room.DownloadInfoEntity2
|
||||
import com.artemchep.keyguard.common.exception.HttpException
|
||||
import com.artemchep.keyguard.common.io.throwIfFatalOrCancellation
|
||||
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
|
||||
import com.artemchep.keyguard.common.service.crypto.FileEncryptor
|
||||
import com.artemchep.keyguard.common.service.download.DownloadProgress
|
||||
import com.artemchep.keyguard.common.usecase.WindowCoroutineScope
|
||||
|
@ -49,6 +50,7 @@ import java.io.IOException
|
|||
|
||||
abstract class DownloadClientJvm(
|
||||
private val cacheDirProvider: CacheDirProvider,
|
||||
private val cryptoGenerator: CryptoGenerator,
|
||||
private val windowCoroutineScope: WindowCoroutineScope,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val fileEncryptor: FileEncryptor,
|
||||
|
@ -81,6 +83,7 @@ abstract class DownloadClientJvm(
|
|||
cacheDirProvider: CacheDirProvider,
|
||||
) : this(
|
||||
cacheDirProvider = cacheDirProvider,
|
||||
cryptoGenerator = directDI.instance(),
|
||||
windowCoroutineScope = directDI.instance(),
|
||||
okHttpClient = directDI.instance(),
|
||||
fileEncryptor = directDI.instance(),
|
||||
|
@ -117,7 +120,6 @@ abstract class DownloadClientJvm(
|
|||
val internalFlow = internalFileLoader(
|
||||
url = url,
|
||||
file = file,
|
||||
fileId = downloadId,
|
||||
fileKey = fileKey,
|
||||
)
|
||||
|
||||
|
@ -210,7 +212,6 @@ abstract class DownloadClientJvm(
|
|||
private fun internalFileLoader(
|
||||
url: String,
|
||||
file: File,
|
||||
fileId: String,
|
||||
fileKey: ByteArray? = null,
|
||||
): Flow<DownloadProgress> = flow {
|
||||
val exists = file.exists()
|
||||
|
@ -227,14 +228,32 @@ abstract class DownloadClientJvm(
|
|||
file.parentFile?.mkdirs()
|
||||
|
||||
val f = channelFlow<DownloadProgress> {
|
||||
val cacheFile = kotlin.runCatching {
|
||||
val cacheFileName = cryptoGenerator.uuid() + ".download"
|
||||
val cacheFileRelativePath = "download_cache/$cacheFileName"
|
||||
cacheDirProvider.get().resolve(cacheFileRelativePath)
|
||||
}.getOrElse { e ->
|
||||
// Report the download as failed if we could not
|
||||
// resolve a cache file.
|
||||
val event = DownloadProgress.Complete(
|
||||
result = e.left(),
|
||||
)
|
||||
send(event)
|
||||
return@channelFlow
|
||||
}
|
||||
|
||||
val result = try {
|
||||
val cacheFileRelativePath = "download_cache/$fileId.download"
|
||||
val cacheFile = cacheDirProvider.get().resolve(cacheFileRelativePath)
|
||||
flap(
|
||||
src = url,
|
||||
dst = cacheFile,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Delete cache file in case of
|
||||
// an error.
|
||||
runCatching {
|
||||
cacheFile.delete()
|
||||
}
|
||||
|
||||
e.throwIfFatalOrCancellation()
|
||||
|
||||
val result = e.left()
|
||||
|
|
|
@ -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.execute.ExecuteCommand
|
||||
import com.artemchep.keyguard.common.service.execute.impl.ExecuteCommandImpl
|
||||
import com.artemchep.keyguard.common.service.export.ExportService
|
||||
import com.artemchep.keyguard.common.service.export.impl.ExportServiceImpl
|
||||
import com.artemchep.keyguard.common.service.export.JsonExportService
|
||||
import com.artemchep.keyguard.common.service.export.impl.JsonExportServiceImpl
|
||||
import com.artemchep.keyguard.common.service.extract.impl.LinkInfoExtractorExecute
|
||||
import com.artemchep.keyguard.common.service.extract.impl.LinkInfoPlatformExtractor
|
||||
import com.artemchep.keyguard.common.service.gpmprivapps.PrivilegedAppsService
|
||||
|
@ -44,6 +44,7 @@ import com.artemchep.keyguard.common.service.placeholder.impl.UrlPlaceholder
|
|||
import com.artemchep.keyguard.common.service.relays.di.emailRelayDiModule
|
||||
import com.artemchep.keyguard.common.service.review.ReviewLog
|
||||
import com.artemchep.keyguard.common.service.review.impl.ReviewLogImpl
|
||||
import com.artemchep.keyguard.common.service.session.VaultSessionLocker
|
||||
import com.artemchep.keyguard.common.service.settings.SettingsReadRepository
|
||||
import com.artemchep.keyguard.common.service.settings.SettingsReadWriteRepository
|
||||
import com.artemchep.keyguard.common.service.settings.impl.SettingsRepositoryImpl
|
||||
|
@ -601,6 +602,11 @@ fun globalModuleJvm() = DI.Module(
|
|||
directDI = this,
|
||||
)
|
||||
}
|
||||
bindSingleton<VaultSessionLocker> {
|
||||
VaultSessionLocker(
|
||||
directDI = this,
|
||||
)
|
||||
}
|
||||
bindSingleton<PutVaultSession> {
|
||||
PutVaultSessionImpl(
|
||||
directDI = this,
|
||||
|
@ -1169,8 +1175,8 @@ fun globalModuleJvm() = DI.Module(
|
|||
directDI = this,
|
||||
)
|
||||
}
|
||||
bindSingleton<ExportService> {
|
||||
ExportServiceImpl(
|
||||
bindSingleton<JsonExportService> {
|
||||
JsonExportServiceImpl(
|
||||
directDI = this,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -19,20 +19,15 @@ import androidx.compose.ui.window.isTraySupported
|
|||
import androidx.compose.ui.window.rememberTrayState
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import com.artemchep.keyguard.common.AppWorker
|
||||
import com.artemchep.keyguard.common.io.attempt
|
||||
import com.artemchep.keyguard.common.io.bind
|
||||
import com.artemchep.keyguard.common.io.effectMap
|
||||
import com.artemchep.keyguard.common.io.flatten
|
||||
import com.artemchep.keyguard.common.io.launchIn
|
||||
import com.artemchep.keyguard.common.io.toIO
|
||||
import com.artemchep.keyguard.common.model.MasterSession
|
||||
import com.artemchep.keyguard.common.model.PersistedSession
|
||||
import com.artemchep.keyguard.common.model.ToastMessage
|
||||
import com.artemchep.keyguard.common.service.session.VaultSessionLocker
|
||||
import com.artemchep.keyguard.common.service.vault.KeyReadWriteRepository
|
||||
import com.artemchep.keyguard.common.usecase.GetAccounts
|
||||
import com.artemchep.keyguard.common.usecase.GetCloseToTray
|
||||
import com.artemchep.keyguard.common.usecase.GetLocale
|
||||
import com.artemchep.keyguard.common.usecase.GetVaultLockAfterTimeout
|
||||
import com.artemchep.keyguard.common.usecase.GetVaultPersist
|
||||
import com.artemchep.keyguard.common.usecase.GetVaultSession
|
||||
import com.artemchep.keyguard.common.usecase.PutVaultSession
|
||||
|
@ -46,14 +41,12 @@ import com.artemchep.keyguard.desktop.util.navigateToFileInFileManager
|
|||
import com.artemchep.keyguard.feature.favicon.Favicon
|
||||
import com.artemchep.keyguard.feature.favicon.FaviconUrl
|
||||
import com.artemchep.keyguard.feature.keyguard.AppRoute
|
||||
import com.artemchep.keyguard.feature.localization.textResource
|
||||
import com.artemchep.keyguard.feature.navigation.LocalNavigationBackHandler
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationController
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationIntent
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationNode
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationRouterBackHandler
|
||||
import com.artemchep.keyguard.platform.CurrentPlatform
|
||||
import com.artemchep.keyguard.platform.LeContext
|
||||
import com.artemchep.keyguard.platform.Platform
|
||||
import com.artemchep.keyguard.platform.lifecycle.LaunchLifecycleProviderEffect
|
||||
import com.artemchep.keyguard.platform.lifecycle.LeLifecycleState
|
||||
|
@ -72,9 +65,7 @@ import io.kamel.image.config.Default
|
|||
import io.kamel.image.config.LocalKamelConfig
|
||||
import io.ktor.http.Url
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
@ -83,7 +74,6 @@ import kotlinx.coroutines.flow.map
|
|||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.datetime.Clock
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
|
||||
|
@ -206,36 +196,10 @@ fun main() {
|
|||
}
|
||||
|
||||
// timeout
|
||||
var timeoutJob: Job? = null
|
||||
val getVaultLockAfterTimeout: GetVaultLockAfterTimeout by appDi.di.instance()
|
||||
val vaultSessionLocker: VaultSessionLocker by appDi.di.instance()
|
||||
processLifecycleProvider.lifecycleStateFlow
|
||||
.onState(minActiveState = LeLifecycleState.RESUMED) {
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
|
||||
try {
|
||||
// suspend forever
|
||||
suspendCancellableCoroutine<Unit> { }
|
||||
} finally {
|
||||
timeoutJob = getVaultLockAfterTimeout()
|
||||
.toIO()
|
||||
// Wait for the timeout duration.
|
||||
.effectMap { duration ->
|
||||
delay(duration)
|
||||
duration
|
||||
}
|
||||
.effectMap {
|
||||
// Clear the current session.
|
||||
val context = LeContext()
|
||||
val session = MasterSession.Empty(
|
||||
reason = textResource(Res.string.lock_reason_inactivity, context),
|
||||
)
|
||||
putVaultSession(session)
|
||||
}
|
||||
.flatten()
|
||||
.attempt()
|
||||
.launchIn(GlobalScope)
|
||||
}
|
||||
vaultSessionLocker.keepAlive()
|
||||
}
|
||||
.launchIn(GlobalScope)
|
||||
|
||||
|
|
Loading…
Reference in New Issue