diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6bb3860a..0beb6419 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -121,6 +121,9 @@ dependencies { implementation("com.github.devnied.emvnfccard:library:3.0.1") implementation("net.grey-panther:natural-comparator:1.1") + implementation("com.github.keemobile:kotpass:0.6.1") + implementation("com.squareup.okio:okio:3.4.0") //Needed for Meta for kotpass + implementation("androidx.datastore:datastore:1.0.0") "githubImplementation"("com.squareup.retrofit2:retrofit:2.9.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9511702a..8e6f2df7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,13 +28,13 @@ android:name=".ui.sync.ImportActivity" android:exported="true" android:theme="@style/AppTheme.NoActionBar"> + - - + diff --git a/app/src/main/java/de/davis/passwordmanager/backup/BackupOperation.kt b/app/src/main/java/de/davis/passwordmanager/backup/BackupOperation.kt new file mode 100644 index 00000000..264578aa --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/BackupOperation.kt @@ -0,0 +1,6 @@ +package de.davis.passwordmanager.backup + +enum class BackupOperation { + IMPORT, + EXPORT +} \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/BackupResourceProvider.kt b/app/src/main/java/de/davis/passwordmanager/backup/BackupResourceProvider.kt new file mode 100644 index 00000000..f22bec49 --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/BackupResourceProvider.kt @@ -0,0 +1,12 @@ +package de.davis.passwordmanager.backup + +import java.io.InputStream +import java.io.OutputStream + +interface BackupResourceProvider { + + suspend fun provideInputStream(): InputStream + suspend fun provideOutputStream(): OutputStream + + suspend fun getFileName(): String +} \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/BackupResult.kt b/app/src/main/java/de/davis/passwordmanager/backup/BackupResult.kt new file mode 100644 index 00000000..6b720201 --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/BackupResult.kt @@ -0,0 +1,7 @@ +package de.davis.passwordmanager.backup + +sealed interface BackupResult { + + open class Success : BackupResult + data class SuccessWithDuplicates(val duplicates: Int) : Success() +} \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/DataBackup.kt b/app/src/main/java/de/davis/passwordmanager/backup/DataBackup.kt index 95239a54..c07b7106 100644 --- a/app/src/main/java/de/davis/passwordmanager/backup/DataBackup.kt +++ b/app/src/main/java/de/davis/passwordmanager/backup/DataBackup.kt @@ -1,128 +1,52 @@ package de.davis.passwordmanager.backup -import android.content.Context -import android.content.DialogInterface -import android.net.Uri -import android.widget.Toast -import androidx.annotation.IntDef -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import de.davis.passwordmanager.R -import de.davis.passwordmanager.dialog.LoadingDialog +import de.davis.passwordmanager.backup.listener.BackupListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.InputStream import java.io.OutputStream -import javax.crypto.AEADBadTagException +abstract class DataBackup(private val backupListener: BackupListener = BackupListener.Empty) { -const val TYPE_EXPORT = 0 -const val TYPE_IMPORT = 1 + protected abstract suspend fun ProgressContext.runImport(inputStream: InputStream): BackupResult + protected abstract suspend fun ProgressContext.runExport(outputStream: OutputStream): BackupResult -@IntDef(TYPE_EXPORT, TYPE_IMPORT) -annotation class Type - -abstract class DataBackup(val context: Context) { - - private lateinit var loadingDialog: LoadingDialog - - @Throws(Exception::class) - internal abstract suspend fun runExport(outputStream: OutputStream): Result - - @Throws(Exception::class) - internal abstract suspend fun runImport(inputStream: InputStream): Result - - open suspend fun execute(@Type type: Int, uri: Uri, onSyncedHandler: OnSyncedHandler? = null) { - val resolver = context.contentResolver - loadingDialog = LoadingDialog(context).apply { - setTitle(if (type == TYPE_EXPORT) R.string.export else R.string.import_str) - setMessage(R.string.wait_text) - } - val alertDialog = withContext(Dispatchers.Main) { loadingDialog.show() } - - try { - withContext(Dispatchers.IO) { - val result: Result = when (type) { - TYPE_EXPORT -> resolver.openOutputStream(uri)?.use { runExport(it) }!! - - TYPE_IMPORT -> resolver.openInputStream(uri)?.use { runImport(it) }!! - - else -> Result.Error("Unexpected error occurred") - } - - handleResult(result, onSyncedHandler) + private val progressContext = object : ProgressContext { + override suspend fun initiateProgress(maxCount: Int) { + withContext(Dispatchers.Main) { + backupListener.initiateProgress(maxCount) } - } catch (e: Exception) { - if (e is NullPointerException) return - error(e) - } finally { - alertDialog.dismiss() } - } - - internal suspend fun error(exception: Exception) { - val msg = if (exception is AEADBadTagException) - context.getString(R.string.password_does_not_match) - else - exception.message - withContext(Dispatchers.Main) { - MaterialAlertDialogBuilder(context).apply { - setTitle(R.string.error_title) - setMessage(msg) - setPositiveButton(R.string.ok) { _: DialogInterface?, _: Int -> } - }.show() + override suspend fun madeProgress(progress: Int) { + withContext(Dispatchers.Main) { + backupListener.onProgressUpdated(progress) + } } - - exception.printStackTrace() } - private suspend fun handleResult(result: Result, onSyncedHandler: OnSyncedHandler?) = - withContext(Dispatchers.Main) { - if (result is Result.Success) { - Toast.makeText( - context, - if (result.type == TYPE_EXPORT) R.string.backup_stored else R.string.backup_restored, - Toast.LENGTH_LONG - ).show() - handleSyncHandler(onSyncedHandler, result) - return@withContext - } - - MaterialAlertDialogBuilder(context).apply { - setPositiveButton(R.string.ok) { _: DialogInterface?, _: Int -> - handleSyncHandler( - onSyncedHandler, - result - ) - } - - if (result is Result.Error) { - setTitle(R.string.error_title) - setMessage(result.message) - } else if (result is Result.Duplicate) { - setTitle(R.string.warning) - setMessage( - context.resources.getQuantityString( - R.plurals.item_existed, - result.count, - result.count - ) - ) + open suspend fun execute( + backupOperation: BackupOperation, + backupResourceProvider: BackupResourceProvider + ) { + backupListener.run { + runCatching { + withContext(Dispatchers.Main) { onStart(backupOperation) } + + backupResourceProvider.run { + progressContext.run { + when (backupOperation) { + BackupOperation.IMPORT -> provideInputStream().use { runImport(it) } + BackupOperation.EXPORT -> provideOutputStream().use { runExport(it) } + } + } } - }.show() - } - - private fun handleSyncHandler(onSyncedHandler: OnSyncedHandler?, result: Result) { - onSyncedHandler?.onSynced(result) - } - internal suspend fun notifyUpdate(current: Int, max: Int) { - withContext(Dispatchers.Main) { - loadingDialog.updateProgress(current, max) + }.onSuccess { + withContext(Dispatchers.Main) { onSuccess(backupOperation, it) } + }.onFailure { + withContext(Dispatchers.Main) { onFailure(backupOperation, it) } + } } } - - interface OnSyncedHandler { - fun onSynced(result: Result?) - } } \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/PasswordProvider.kt b/app/src/main/java/de/davis/passwordmanager/backup/PasswordProvider.kt new file mode 100644 index 00000000..c0c68d5f --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/PasswordProvider.kt @@ -0,0 +1,10 @@ +package de.davis.passwordmanager.backup + +interface PasswordProvider { + + suspend operator fun invoke( + backupOperation: BackupOperation, + backupResourceProvider: BackupResourceProvider, + callback: suspend (password: String) -> Unit + ) +} \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/ProgressContext.kt b/app/src/main/java/de/davis/passwordmanager/backup/ProgressContext.kt new file mode 100644 index 00000000..1d6d023a --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/ProgressContext.kt @@ -0,0 +1,6 @@ +package de.davis.passwordmanager.backup + +interface ProgressContext { + suspend fun initiateProgress(maxCount: Int) + suspend fun madeProgress(progress: Int) +} \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/Result.kt b/app/src/main/java/de/davis/passwordmanager/backup/Result.kt deleted file mode 100644 index 94d2d102..00000000 --- a/app/src/main/java/de/davis/passwordmanager/backup/Result.kt +++ /dev/null @@ -1,7 +0,0 @@ -package de.davis.passwordmanager.backup - -sealed class Result { - open class Success(@field:Type val type: Int) : Result() - class Error(val message: String) : Result() - class Duplicate(val count: Int) : Result() -} \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/SecureDataBackup.kt b/app/src/main/java/de/davis/passwordmanager/backup/SecureDataBackup.kt index 66138c84..354425d9 100644 --- a/app/src/main/java/de/davis/passwordmanager/backup/SecureDataBackup.kt +++ b/app/src/main/java/de/davis/passwordmanager/backup/SecureDataBackup.kt @@ -1,73 +1,39 @@ package de.davis.passwordmanager.backup -import android.content.Context -import android.content.DialogInterface -import android.net.Uri -import android.text.InputType -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.content.res.AppCompatResources -import com.google.android.material.textfield.TextInputLayout -import de.davis.passwordmanager.R -import de.davis.passwordmanager.dialog.EditDialogBuilder -import de.davis.passwordmanager.ui.views.InformationView.Information -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import de.davis.passwordmanager.backup.listener.BackupListener +import java.io.InputStream +import java.io.OutputStream -abstract class SecureDataBackup(context: Context) : DataBackup(context) { +abstract class SecureDataBackup( + private val passwordProvider: PasswordProvider, + backupListener: BackupListener = BackupListener.Empty +) : DataBackup(backupListener) { - lateinit var password: String + private lateinit var password: String - private suspend fun requestPassword( - @Type type: Int, - uri: Uri, - onSyncedHandler: OnSyncedHandler? - ) { - val information = Information().apply { - hint = context.getString(R.string.password) - inputType = InputType.TYPE_TEXT_VARIATION_PASSWORD - isSecret = true - } - withContext(Dispatchers.Main) { - EditDialogBuilder(context).apply { - setTitle(R.string.password) - setPositiveButton(R.string.yes) { _: DialogInterface?, _: Int -> } - setButtonListener( - DialogInterface.BUTTON_POSITIVE, - R.string.yes - ) { dialog, _, password -> - /* - Needed for the error message that appears when the password (field) is empty. - otherwise the dialog would close itself - */ + protected abstract suspend fun ProgressContext.runImport( + inputStream: InputStream, + password: String + ): BackupResult - val alertDialog = dialog as AlertDialog - if (password.isEmpty()) { - alertDialog.findViewById(R.id.textInputLayout)?.error = - context.getString(R.string.is_not_filled_in) - return@setButtonListener - } - alertDialog.dismiss() - this@SecureDataBackup.password = password - CoroutineScope(Job() + Dispatchers.IO).launch { - super.execute(type, uri, onSyncedHandler) - } - } - withInformation(information) - withStartIcon( - AppCompatResources.getDrawable( - context, - R.drawable.ic_baseline_password_24 - ) - ) - setCancelable(type == TYPE_IMPORT) - }.show() - } - } + protected abstract suspend fun ProgressContext.runExport( + outputStream: OutputStream, + password: String + ): BackupResult - override suspend fun execute(@Type type: Int, uri: Uri, onSyncedHandler: OnSyncedHandler?) { - requestPassword(type, uri, onSyncedHandler) + final override suspend fun ProgressContext.runImport(inputStream: InputStream): BackupResult = + runImport(inputStream, password) + + final override suspend fun ProgressContext.runExport(outputStream: OutputStream): BackupResult = + runExport(outputStream, password) + + override suspend fun execute( + backupOperation: BackupOperation, + backupResourceProvider: BackupResourceProvider + ) { + passwordProvider(backupOperation, backupResourceProvider) { + password = it + super.execute(backupOperation, backupResourceProvider) + } } } \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/impl/AndroidBackupListener.kt b/app/src/main/java/de/davis/passwordmanager/backup/impl/AndroidBackupListener.kt new file mode 100644 index 00000000..aaa30f2b --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/impl/AndroidBackupListener.kt @@ -0,0 +1,85 @@ +package de.davis.passwordmanager.backup.impl + +import android.content.Context +import android.content.DialogInterface +import android.widget.Toast +import app.keemobile.kotpass.errors.CryptoError +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import de.davis.passwordmanager.R +import de.davis.passwordmanager.backup.BackupOperation +import de.davis.passwordmanager.backup.BackupResult +import de.davis.passwordmanager.backup.listener.BackupListener +import de.davis.passwordmanager.dialog.LoadingDialog + +open class AndroidBackupListener(private val context: Context) : BackupListener { + + private lateinit var loadingDialogBuilder: LoadingDialog + + override fun initiateProgress(maxCount: Int) { + loadingDialogBuilder.setMax(maxCount) + } + + override fun onProgressUpdated(progress: Int) { + loadingDialogBuilder.updateProgress(progress) + } + + override fun onStart(backupOperation: BackupOperation) { + loadingDialogBuilder = LoadingDialog(context).apply { + setTitle(if (backupOperation == BackupOperation.EXPORT) R.string.export else R.string.import_str) + setMessage(R.string.wait_text) + }.also { it.show() } + } + + override fun onSuccess(backupOperation: BackupOperation, backupResult: BackupResult) { + loadingDialogBuilder.dismiss() + when (backupResult) { + is BackupResult.SuccessWithDuplicates -> { + MaterialAlertDialogBuilder(context).apply { + setPositiveButton(R.string.ok) { _: DialogInterface?, _: Int -> } + + setTitle(R.string.warning) + setMessage( + context.resources.getQuantityString( + R.plurals.item_existed, + backupResult.duplicates, + backupResult.duplicates + ) + ) + }.show() + } + + is BackupResult.Success -> { + Toast.makeText(context, R.string.backup_restored, Toast.LENGTH_SHORT).show() + } + } + } + + override fun onFailure(backupOperation: BackupOperation, throwable: Throwable) { + loadingDialogBuilder.dismiss() + + val msg = when (throwable) { + is CryptoError.InvalidKey -> context.getString(R.string.password_does_not_match) + else -> { + when (throwable.message?.trim()) { + MSG_ROW_NUMBER_ERROR -> { + context.getString(R.string.csv_row_number_error) + } + + else -> throwable.message + } + } + } + + MaterialAlertDialogBuilder(context).apply { + setTitle(R.string.error_title) + setMessage(msg) + setPositiveButton(R.string.ok) { _: DialogInterface?, _: Int -> } + }.show() + + throwable.printStackTrace() + } + + companion object { + const val MSG_ROW_NUMBER_ERROR = "error_row_number" + } +} \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/impl/AndroidPasswordProvider.kt b/app/src/main/java/de/davis/passwordmanager/backup/impl/AndroidPasswordProvider.kt new file mode 100644 index 00000000..dc407fd7 --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/impl/AndroidPasswordProvider.kt @@ -0,0 +1,85 @@ +package de.davis.passwordmanager.backup.impl + +import android.content.Context +import android.content.DialogInterface +import android.os.IBinder +import android.text.InputType +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources +import com.google.android.material.textfield.TextInputLayout +import de.davis.passwordmanager.R +import de.davis.passwordmanager.backup.BackupOperation +import de.davis.passwordmanager.backup.BackupResourceProvider +import de.davis.passwordmanager.backup.PasswordProvider +import de.davis.passwordmanager.dialog.EditDialogBuilder +import de.davis.passwordmanager.ui.views.InformationView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class AndroidPasswordProvider(private val context: Context) : PasswordProvider { + override suspend fun invoke( + backupOperation: BackupOperation, + backupResourceProvider: BackupResourceProvider, + callback: suspend (password: String) -> Unit + ) { + withContext(Dispatchers.Main) { + val information = InformationView.Information().apply { + hint = context.getString(R.string.password) + inputType = InputType.TYPE_TEXT_VARIATION_PASSWORD + isSecret = true + } + + EditDialogBuilder(context).apply { + setTitle(R.string.password) + setMessage( + context.getString( + R.string.enter_password_for_s, + backupResourceProvider.getFileName() + ) + ) + setPositiveButton(R.string.yes) { _: DialogInterface?, _: Int -> } + setButtonListener( + DialogInterface.BUTTON_POSITIVE, + R.string.yes + ) { dialog, _, password -> + /* + Needed for the error message that appears when the password (field) is empty. + otherwise the dialog would close itself + */ + + val alertDialog = dialog as AlertDialog + if (password.isEmpty()) { + alertDialog.findViewById(R.id.textInputLayout)?.error = + context.getString(R.string.is_not_filled_in) + return@setButtonListener + } + hideKeyboardFrom( + context, + alertDialog.window?.decorView?.windowToken + ) + alertDialog.dismiss() + CoroutineScope(Job() + Dispatchers.IO).launch { + callback(password) + } + } + withInformation(information) + withStartIcon( + AppCompatResources.getDrawable( + context, + R.drawable.ic_baseline_password_24 + ) + ) + setCancelable(backupOperation == BackupOperation.IMPORT) + }.show() + } + } + + private fun hideKeyboardFrom(context: Context, windowToken: IBinder?) { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(windowToken, 0) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/csv/CsvBackup.kt b/app/src/main/java/de/davis/passwordmanager/backup/impl/CsvBackup.kt similarity index 68% rename from app/src/main/java/de/davis/passwordmanager/backup/csv/CsvBackup.kt rename to app/src/main/java/de/davis/passwordmanager/backup/impl/CsvBackup.kt index cb6b0fed..56df7901 100644 --- a/app/src/main/java/de/davis/passwordmanager/backup/csv/CsvBackup.kt +++ b/app/src/main/java/de/davis/passwordmanager/backup/impl/CsvBackup.kt @@ -1,14 +1,12 @@ -package de.davis.passwordmanager.backup.csv +package de.davis.passwordmanager.backup.impl -import android.content.Context import com.opencsv.CSVReaderBuilder import com.opencsv.CSVWriterBuilder import com.opencsv.validators.RowFunctionValidator -import de.davis.passwordmanager.R +import de.davis.passwordmanager.backup.BackupResult import de.davis.passwordmanager.backup.DataBackup -import de.davis.passwordmanager.backup.Result -import de.davis.passwordmanager.backup.TYPE_EXPORT -import de.davis.passwordmanager.backup.TYPE_IMPORT +import de.davis.passwordmanager.backup.ProgressContext +import de.davis.passwordmanager.backup.listener.BackupListener import de.davis.passwordmanager.database.ElementType import de.davis.passwordmanager.database.SecureElementManager import de.davis.passwordmanager.database.dtos.SecureElement @@ -18,33 +16,30 @@ import java.io.InputStreamReader import java.io.OutputStream import java.io.OutputStreamWriter -class CsvBackup(context: Context) : DataBackup(context) { - @Throws(Exception::class) - override suspend fun runImport(inputStream: InputStream): Result { +class CsvBackup(backupListener: BackupListener = BackupListener.Empty) : + DataBackup(backupListener = backupListener) { + + override suspend fun ProgressContext.runImport(inputStream: InputStream): BackupResult { val csvReader = CSVReaderBuilder(InputStreamReader(inputStream)).apply { withSkipLines(1) withRowValidator( RowFunctionValidator( { s: Array -> s.size == 5 }, - context.getString(R.string.csv_row_number_error) - ) - ) - withRowValidator( - RowFunctionValidator( - { s: Array -> s.size == 5 }, - context.getString(R.string.csv_row_number_error) + AndroidBackupListener.MSG_ROW_NUMBER_ERROR ) ) }.build() - var line: Array + var line: Array = emptyArray() val elements: List = SecureElementManager.getSecureElements(ElementType.PASSWORD.typeId) var existed = 0 csvReader.use { - while (csvReader.readNext().also { line = it } != null) { - if (line[0].isEmpty() || line[3].isEmpty()) // name and password must not be empty + while (csvReader.readNext()?.also { line = it } != null) { + // Skip lines where either the name (index 0) or password (index 3) is empty. + // Assumes length validation by RowValidator, thus preventing IndexOutOfBoundsException. + if (line[0].isEmpty() || line[3].isEmpty()) continue val title = line[0] @@ -65,11 +60,10 @@ class CsvBackup(context: Context) : DataBackup(context) { SecureElementManager.insertElement(SecureElement(title, details)) } } - return if (existed != 0) Result.Duplicate(existed) else Result.Success(TYPE_IMPORT) + return if (existed != 0) BackupResult.SuccessWithDuplicates(existed) else BackupResult.Success() } - @Throws(Exception::class) - override suspend fun runExport(outputStream: OutputStream): Result { + override suspend fun ProgressContext.runExport(outputStream: OutputStream): BackupResult { val csvWriter = CSVWriterBuilder(OutputStreamWriter(outputStream)).build() val elements: List = @@ -88,6 +82,6 @@ class CsvBackup(context: Context) : DataBackup(context) { }) it.flush() } - return Result.Success(TYPE_EXPORT) + return BackupResult.Success() } } \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/impl/KdbxBackup.kt b/app/src/main/java/de/davis/passwordmanager/backup/impl/KdbxBackup.kt new file mode 100644 index 00000000..b7ed411c --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/impl/KdbxBackup.kt @@ -0,0 +1,128 @@ +package de.davis.passwordmanager.backup.impl + +import app.keemobile.kotpass.constants.BasicField +import app.keemobile.kotpass.cryptography.EncryptedValue +import app.keemobile.kotpass.database.Credentials +import app.keemobile.kotpass.database.KeePassDatabase +import app.keemobile.kotpass.database.decode +import app.keemobile.kotpass.database.encode +import app.keemobile.kotpass.database.modifiers.modifyParentGroup +import app.keemobile.kotpass.database.traverse +import app.keemobile.kotpass.models.Entry +import app.keemobile.kotpass.models.EntryFields +import app.keemobile.kotpass.models.EntryValue +import app.keemobile.kotpass.models.Meta +import de.davis.passwordmanager.backup.BackupResult +import de.davis.passwordmanager.backup.PasswordProvider +import de.davis.passwordmanager.backup.ProgressContext +import de.davis.passwordmanager.backup.SecureDataBackup +import de.davis.passwordmanager.backup.listener.BackupListener +import de.davis.passwordmanager.database.ElementType +import de.davis.passwordmanager.database.SecureElementManager +import de.davis.passwordmanager.database.dtos.SecureElement +import de.davis.passwordmanager.database.entities.Tag +import de.davis.passwordmanager.database.entities.details.password.PasswordDetails +import de.davis.passwordmanager.database.entities.onlyCustoms +import java.io.InputStream +import java.io.OutputStream +import java.util.UUID + +class KdbxBackup( + passwordProvider: PasswordProvider, + backupListener: BackupListener = BackupListener.Empty, +) : SecureDataBackup(passwordProvider, backupListener) { + private val credentialFactory: (String) -> Credentials = { + Credentials.from(EncryptedValue.fromString(it)) + } + + override suspend fun ProgressContext.runImport( + inputStream: InputStream, + password: String + ): BackupResult { + val database = inputStream.use { KeePassDatabase.decode(it, credentialFactory(password)) } + + val existingElements = SecureElementManager.getSecureElements(ElementType.PASSWORD.typeId) + val elementsToAdd = mutableListOf() + + var count = 0 + database.traverse { + when (it) { + is Entry -> { + it.fields.run { + if (title?.content == null || this.password?.content == null) + return@run + + val element = SecureElement( + title?.content!!, + PasswordDetails( + this.password?.content!!, + url?.content.orEmpty(), + userName?.content.orEmpty() + ), + it.tags.map { tagName -> Tag(tagName) }, + ) + + if (existingElements.any { e -> e.title == element.title && e.detail == element.detail }) { + count++ + } else { + elementsToAdd += element + } + } + } + + else -> {} + } + } + + initiateProgress(elementsToAdd.size) + elementsToAdd.forEach { + SecureElementManager.insertElement(it) + madeProgress(elementsToAdd.indexOf(it) + 1) + } + + return if (count == 0) BackupResult.Success() else BackupResult.SuccessWithDuplicates(count) + } + + override suspend fun ProgressContext.runExport( + outputStream: OutputStream, + password: String + ): BackupResult { + val elements = SecureElementManager.getSecureElements(ElementType.PASSWORD.typeId) + val database = KeePassDatabase.Ver4x.create("", META, credentialFactory(password)).run { + modifyParentGroup { + copy(entries = elements.map { + val details = it.detail as PasswordDetails + Entry( + UUID.randomUUID(), + fields = EntryFields.of( + BasicField.Title() to EntryValue.Plain(it.title), + BasicField.Url() to EntryValue.Plain(details.origin), + BasicField.UserName() to EntryValue.Plain(details.username), + BasicField.Password() to EntryValue.Encrypted( + EncryptedValue.fromString(details.password) + ), + ), + tags = it.tags.onlyCustoms().map { tag -> tag.name } + ) + }) + } + } + + outputStream.use { + database.encode(it) + } + + return BackupResult.Success() + } + + companion object { + private val META = Meta( + generator = "KeyGo - Digital Vault", + name = "Elements", + description = "Securely stored elements in KeyGo Digital Vault. This database " + + "contains sensitive and encrypted data items such as passwords, personal " + + "information, and secure notes, meticulously organized for optimal security " + + "and accessibility.", + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/impl/UriBackupResourceProvider.kt b/app/src/main/java/de/davis/passwordmanager/backup/impl/UriBackupResourceProvider.kt new file mode 100644 index 00000000..7427d37e --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/impl/UriBackupResourceProvider.kt @@ -0,0 +1,44 @@ +package de.davis.passwordmanager.backup.impl + +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.provider.OpenableColumns +import de.davis.passwordmanager.backup.BackupResourceProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.io.OutputStream + +class UriBackupResourceProvider( + private var uri: Uri, + private var contentResolver: ContentResolver +) : BackupResourceProvider { + + override suspend fun provideInputStream(): InputStream = contentResolver.openInputStream(uri) + ?: throw IllegalStateException("ContentResolver returned null") + + override suspend fun provideOutputStream(): OutputStream = contentResolver.openOutputStream(uri) + ?: throw IllegalStateException("ContentResolver returned null") + + override suspend fun getFileName(): String = contentResolver.getFileName(uri) ?: "Unknown" + + private suspend fun ContentResolver.getFileName(uri: Uri): String? = + withContext(Dispatchers.IO) { + var fileName: String? = null + if (uri.scheme == ContentResolver.SCHEME_CONTENT) { + val cursor: Cursor? = query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + @SuppressLint("Range") + fileName = it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME)) + } + } + } else if (uri.scheme == ContentResolver.SCHEME_FILE) { + fileName = uri.lastPathSegment + } + + fileName + } +} \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/keygo/KeyGoBackup.kt b/app/src/main/java/de/davis/passwordmanager/backup/keygo/KeyGoBackup.kt deleted file mode 100644 index e0f81b34..00000000 --- a/app/src/main/java/de/davis/passwordmanager/backup/keygo/KeyGoBackup.kt +++ /dev/null @@ -1,128 +0,0 @@ -package de.davis.passwordmanager.backup.keygo - -import android.content.Context -import com.google.gson.GsonBuilder -import com.google.gson.JsonArray -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonParseException -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer -import com.google.gson.reflect.TypeToken -import de.davis.passwordmanager.R -import de.davis.passwordmanager.backup.Result -import de.davis.passwordmanager.backup.SecureDataBackup -import de.davis.passwordmanager.backup.TYPE_EXPORT -import de.davis.passwordmanager.backup.TYPE_IMPORT -import de.davis.passwordmanager.database.ElementType -import de.davis.passwordmanager.database.SecureElementManager -import de.davis.passwordmanager.database.dtos.SecureElement -import de.davis.passwordmanager.database.entities.details.ElementDetail -import de.davis.passwordmanager.database.entities.details.password.PasswordDetails -import de.davis.passwordmanager.gson.strategies.ExcludeAnnotationStrategy -import de.davis.passwordmanager.security.Cryptography -import org.apache.commons.io.IOUtils -import java.io.InputStream -import java.io.OutputStream -import java.lang.reflect.Type - -class KeyGoBackup(context: Context) : SecureDataBackup(context) { - - class ElementDetailTypeAdapter : JsonSerializer, - JsonDeserializer { - @Throws(JsonParseException::class) - override fun deserialize( - json: JsonElement, - typeOfT: Type, - context: JsonDeserializationContext - ): ElementDetail { - json.asJsonObject.run { - val type = this["type"].asInt - if (type == ElementType.PASSWORD.typeId) { - val passwordArray = JsonArray().apply { - for (b in Cryptography.encryptAES(this@run["password"].asString.toByteArray())) { - add(b) - } - } - - add("password", passwordArray) - } - - return context.deserialize( - json, - ElementType.getTypeByTypeId(type).elementDetailClass - ) - } - } - - override fun serialize( - src: ElementDetail, - typeOfSrc: Type, - context: JsonSerializationContext - ): JsonElement { - val jsonObject = context.serialize(src) - jsonObject.asJsonObject.run { - if (src is PasswordDetails) addProperty( - "password", - src.password - ) - addProperty("type", src.elementType.typeId) - } - - return jsonObject - } - } - - private val gson = GsonBuilder().apply { - registerTypeAdapter(ElementDetail::class.java, ElementDetailTypeAdapter()) - setExclusionStrategies(ExcludeAnnotationStrategy()) - }.create() - - @Throws(Exception::class) - override suspend fun runImport(inputStream: InputStream): Result { - var file = IOUtils.toByteArray(inputStream) - if (file.isEmpty()) return Result.Error(context.getString(R.string.invalid_file_length)) - - file = Cryptography.decryptWithPwd(file, password) - - val list: List = try { - gson.fromJson( - String(file), - object : TypeToken>() {}.type - ) - } catch (e: Exception) { - return Result.Error(context.getString(R.string.invalid_file)) - } - - val elements: List = SecureElementManager.getSecureElements() - - var existed = 0 - val length = list.size - - for (i in 0 until length) { - val element = list[i] - if (elements.any { it.title == element.title && it.detail == element.detail }) { - existed++ - notifyUpdate(i + 1, length) - continue - } - SecureElementManager.insertElement(element) - notifyUpdate(i + 1, length) - } - return if (existed != 0) Result.Duplicate(existed) else Result.Success(TYPE_IMPORT) - } - - @Throws(Exception::class) - override suspend fun runExport(outputStream: OutputStream): Result { - val elements: List = SecureElementManager.getSecureElements() - - val json = gson.toJson(elements) - - outputStream.use { - it.write(Cryptography.encryptWithPwd(json.toByteArray(), password)) - } - - return Result.Success(TYPE_EXPORT) - } -} \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/backup/listener/BackupListener.kt b/app/src/main/java/de/davis/passwordmanager/backup/listener/BackupListener.kt new file mode 100644 index 00000000..abe3d169 --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/listener/BackupListener.kt @@ -0,0 +1,20 @@ +package de.davis.passwordmanager.backup.listener + +import de.davis.passwordmanager.backup.BackupOperation +import de.davis.passwordmanager.backup.BackupResult + +interface BackupListener { + fun onStart(backupOperation: BackupOperation) + fun onSuccess(backupOperation: BackupOperation, backupResult: BackupResult) + fun onFailure(backupOperation: BackupOperation, throwable: Throwable) + + fun initiateProgress(maxCount: Int) {} + + fun onProgressUpdated(progress: Int) {} + + data object Empty : BackupListener { + override fun onStart(backupOperation: BackupOperation) {} + override fun onSuccess(backupOperation: BackupOperation, backupResult: BackupResult) {} + override fun onFailure(backupOperation: BackupOperation, throwable: Throwable) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/dialog/LoadingDialog.java b/app/src/main/java/de/davis/passwordmanager/dialog/LoadingDialog.java index ec5f888b..aa00a179 100644 --- a/app/src/main/java/de/davis/passwordmanager/dialog/LoadingDialog.java +++ b/app/src/main/java/de/davis/passwordmanager/dialog/LoadingDialog.java @@ -5,22 +5,31 @@ import android.view.View; import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; import de.davis.passwordmanager.databinding.LoadingLayoutBinding; public class LoadingDialog extends BaseDialogBuilder { private LoadingLayoutBinding binding; + private AlertDialog alertDialog; public LoadingDialog(@NonNull Context context) { super(context); setCancelable(false); } - public void updateProgress(int current, int max){ - double progress = (current * 100d / max); + public void dismiss(){ + alertDialog.dismiss(); + } + + public void setMax(int max){ + binding.progress.setMax(max); + } + + public void updateProgress(int progress) { + binding.progress.setProgressCompat(progress, true); binding.progress.setIndeterminate(false); - binding.progress.setProgressCompat((int) progress, true); } @Override @@ -28,4 +37,10 @@ public View onCreateView(LayoutInflater inflater) { binding = LoadingLayoutBinding.inflate(inflater); return binding.getRoot(); } + + @NonNull + @Override + public AlertDialog create() { + return alertDialog = super.create(); + } } diff --git a/app/src/main/java/de/davis/passwordmanager/ktx/Parcelable.kt b/app/src/main/java/de/davis/passwordmanager/ktx/Parcelable.kt index 8ce46f7a..ac66f3ef 100644 --- a/app/src/main/java/de/davis/passwordmanager/ktx/Parcelable.kt +++ b/app/src/main/java/de/davis/passwordmanager/ktx/Parcelable.kt @@ -4,9 +4,8 @@ package de.davis.passwordmanager.ktx import android.os.Build import android.os.Bundle -import android.os.Parcelable -fun Bundle.getParcelableCompat(key: String, clazz: Class): A? { +fun Bundle.getParcelableCompat(key: String, clazz: Class): A? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { classLoader = clazz.classLoader getParcelable(key, clazz) diff --git a/app/src/main/java/de/davis/passwordmanager/ui/backup/BackupFragment.kt b/app/src/main/java/de/davis/passwordmanager/ui/backup/BackupFragment.kt index bf7d0546..9c3c8873 100644 --- a/app/src/main/java/de/davis/passwordmanager/ui/backup/BackupFragment.kt +++ b/app/src/main/java/de/davis/passwordmanager/ui/backup/BackupFragment.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.os.Bundle import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContracts import androidx.core.os.bundleOf import androidx.lifecycle.lifecycleScope @@ -14,104 +15,137 @@ import androidx.preference.PreferenceFragmentCompat import com.google.android.material.dialog.MaterialAlertDialogBuilder import de.davis.passwordmanager.PasswordManagerApplication import de.davis.passwordmanager.R -import de.davis.passwordmanager.backup.TYPE_EXPORT -import de.davis.passwordmanager.backup.TYPE_IMPORT -import de.davis.passwordmanager.backup.Type -import de.davis.passwordmanager.backup.csv.CsvBackup -import de.davis.passwordmanager.backup.keygo.KeyGoBackup +import de.davis.passwordmanager.backup.BackupOperation +import de.davis.passwordmanager.backup.DataBackup +import de.davis.passwordmanager.backup.impl.AndroidBackupListener +import de.davis.passwordmanager.backup.impl.AndroidPasswordProvider +import de.davis.passwordmanager.backup.impl.CsvBackup +import de.davis.passwordmanager.backup.impl.KdbxBackup +import de.davis.passwordmanager.backup.impl.UriBackupResourceProvider +import de.davis.passwordmanager.ktx.getParcelableCompat import de.davis.passwordmanager.ui.auth.AuthenticationRequest import de.davis.passwordmanager.ui.auth.createRequestAuthenticationIntent import kotlinx.coroutines.launch -private const val TYPE_KEYGO = "keygo" -private const val TYPE_CSV = "csv" - class BackupFragment : PreferenceFragmentCompat() { + private lateinit var kdbxBackup: KdbxBackup + private lateinit var csvBackup: CsvBackup + private lateinit var csvImportLauncher: ActivityResultLauncher> private lateinit var csvExportLauncher: ActivityResultLauncher - private lateinit var keyGoImportLauncher: ActivityResultLauncher> - private lateinit var keyGoExportLauncher: ActivityResultLauncher + private lateinit var kdbxImportLauncher: ActivityResultLauncher> + private lateinit var kdbxExportLauncher: ActivityResultLauncher + val auth: ActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult? -> if (result == null) return@registerForActivityResult val data = result.data?.extras ?: return@registerForActivityResult - val formatType = data.getString("format_type") ?: return@registerForActivityResult - when (data.getInt("type")) { - TYPE_EXPORT -> { - if (formatType == TYPE_CSV) { + val formatType = data.getString(EXTRA_BACKUP_FORMAT) ?: return@registerForActivityResult + val backupOperation = + data.getParcelableCompat(EXTRA_BACKUP_TYPE, BackupOperation::class.java) + ?: return@registerForActivityResult + + when (backupOperation) { + BackupOperation.EXPORT -> { + if (formatType == BACKUP_FORMAT_CSV) { (requireActivity().application as PasswordManagerApplication).disableReAuthentication() - csvExportLauncher.launch("keygo-passwords.csv") - } else if (formatType == TYPE_KEYGO) { + csvExportLauncher.launch(DEFAULT_FILE_NAME_CSV) + } else if (formatType == BACKUP_FORMAT_KDBX) { (requireActivity().application as PasswordManagerApplication).disableReAuthentication() - keyGoExportLauncher.launch("elements.keygo") + kdbxExportLauncher.launch(DEFAULT_FILE_NAME_KDBX) } } - TYPE_IMPORT -> { - if (formatType == TYPE_CSV) { + BackupOperation.IMPORT -> { + if (formatType == BACKUP_FORMAT_CSV) { (requireActivity().application as PasswordManagerApplication).disableReAuthentication() - csvImportLauncher.launch(arrayOf("text/comma-separated-values")) - } else if (formatType == TYPE_KEYGO) { + csvImportLauncher.launch(arrayOf(MIME_TYPE_CSV)) + } else if (formatType == BACKUP_FORMAT_KDBX) { (requireActivity().application as PasswordManagerApplication).disableReAuthentication() - keyGoImportLauncher.launch(arrayOf("application/octet-stream")) + kdbxImportLauncher.launch(arrayOf(MIME_TYPE_KDBX)) } } } } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.backup_preferences) - - CsvBackup(requireContext()).run { - csvImportLauncher = - registerForActivityResult, Uri>(ActivityResultContracts.OpenDocument()) { result: Uri? -> - result?.let { - lifecycleScope.launch { - execute(TYPE_IMPORT, result) - } - } + @Suppress("UNCHECKED_CAST") + private fun DataBackup.useForActivityResultRegistration(backupOperation: BackupOperation): ActivityResultLauncher { + val contract = when (backupOperation) { + BackupOperation.IMPORT -> ActivityResultContracts.OpenDocument() + BackupOperation.EXPORT -> { + val mimeType = when (this) { + is CsvBackup -> MIME_TYPE_CSV + is KdbxBackup -> MIME_TYPE_KDBX + else -> throw IllegalStateException("Unregistered DataBackup") } - csvExportLauncher = - registerForActivityResult(ActivityResultContracts.CreateDocument("text/comma-separated-values")) { result: Uri? -> - result?.let { - lifecycleScope.launch { - execute(TYPE_EXPORT, result) - } - } - } + ActivityResultContracts.CreateDocument(mimeType) + } } - KeyGoBackup(requireContext()).run { - keyGoExportLauncher = - registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { result: Uri? -> - result?.let { - lifecycleScope.launch { - execute(TYPE_EXPORT, result) - } - } - } - keyGoImportLauncher = - registerForActivityResult, Uri>(ActivityResultContracts.OpenDocument()) { result: Uri? -> - result?.let { - lifecycleScope.launch { - execute(TYPE_IMPORT, result) - } - } + return registerBackupLauncher(contract, this) as ActivityResultLauncher + } + + private fun registerBackupLauncher( + contract: ActivityResultContract, + backup: DataBackup + ): ActivityResultLauncher { + val createStreamProvider = { uri: Uri -> + UriBackupResourceProvider(uri, requireContext().contentResolver) + } + + return registerForActivityResult(contract) { uri: Uri? -> + uri?.let { + lifecycleScope.launch { + backup.execute( + if (contract is ActivityResultContracts.OpenDocument) BackupOperation.IMPORT else BackupOperation.EXPORT, + createStreamProvider(uri) + ) } + } } + } + private fun Preference.addLaunchAuthFunctionality( + backupOperation: BackupOperation, + bT: String + ) { + onPreferenceClickListener = Preference.OnPreferenceClickListener { _: Preference? -> + launchAuth(backupOperation, bT) + true + } + } - findPreference(getString(R.string.preference_import_csv))?.onPreferenceClickListener = - Preference.OnPreferenceClickListener { _: Preference? -> - launchAuth(TYPE_IMPORT, TYPE_CSV) - true - } + private fun initiateBackupImpl() { + AndroidBackupListener(requireContext()).also { + kdbxBackup = KdbxBackup(AndroidPasswordProvider(requireContext()), it) + csvBackup = CsvBackup(it) + } + } + private fun initiateLaunchers() { + initiateBackupImpl() + + csvImportLauncher = csvBackup.useForActivityResultRegistration(BackupOperation.IMPORT) + csvExportLauncher = csvBackup.useForActivityResultRegistration(BackupOperation.EXPORT) + + kdbxImportLauncher = kdbxBackup.useForActivityResultRegistration(BackupOperation.IMPORT) + kdbxExportLauncher = kdbxBackup.useForActivityResultRegistration(BackupOperation.EXPORT) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.backup_preferences) + + initiateLaunchers() + + findPreference(getString(R.string.preference_import_csv))?.addLaunchAuthFunctionality( + BackupOperation.IMPORT, + BACKUP_FORMAT_CSV + ) findPreference(getString(R.string.preference_export_csv))?.onPreferenceClickListener = Preference.OnPreferenceClickListener { _: Preference? -> MaterialAlertDialogBuilder( @@ -120,43 +154,54 @@ class BackupFragment : PreferenceFragmentCompat() { ).apply { setTitle(R.string.warning) setMessage(R.string.csv_export_warning) - setPositiveButton(R.string.text_continue) { _: DialogInterface?, _: Int -> - launchAuth( - TYPE_EXPORT, - TYPE_CSV - ) + setNegativeButton(R.string.text_continue) { _: DialogInterface?, _: Int -> + launchAuth(BackupOperation.EXPORT, BACKUP_FORMAT_CSV) } - setNegativeButton(R.string.use_keygo) { _: DialogInterface?, _: Int -> - launchAuth( - TYPE_EXPORT, - TYPE_KEYGO - ) + setPositiveButton(R.string.use_kdbx) { _: DialogInterface?, _: Int -> + launchAuth(BackupOperation.EXPORT, BACKUP_FORMAT_KDBX) } setNeutralButton(R.string.cancel) { dialog: DialogInterface, _: Int -> dialog.dismiss() } }.show() true } - findPreference(getString(R.string.preference_export_keygo))?.onPreferenceClickListener = - Preference.OnPreferenceClickListener { _: Preference? -> - launchAuth(TYPE_EXPORT, TYPE_KEYGO) - true - } - findPreference(getString(R.string.preference_import_keygo))?.onPreferenceClickListener = - Preference.OnPreferenceClickListener { _: Preference? -> - launchAuth(TYPE_IMPORT, TYPE_KEYGO) - true - } + findPreference(getString(R.string.preference_export_kdbx))?.addLaunchAuthFunctionality( + BackupOperation.EXPORT, + BACKUP_FORMAT_KDBX + ) + findPreference(getString(R.string.preference_import_kdbx))?.addLaunchAuthFunctionality( + BackupOperation.IMPORT, + BACKUP_FORMAT_KDBX + ) } - private fun launchAuth(@Type type: Int, format: String) { + private fun launchAuth(backupOperation: BackupOperation, format: String) { auth.launch( requireContext().createRequestAuthenticationIntent( AuthenticationRequest.Builder().apply { withMessage(R.string.authenticate_to_proceed) - withAdditionalExtras(bundleOf("type" to type, "format_type" to format)) + withAdditionalExtras( + bundleOf( + EXTRA_BACKUP_TYPE to backupOperation, + EXTRA_BACKUP_FORMAT to format + ) + ) }.build() ) ) } + + companion object { + private const val MIME_TYPE_CSV = "text/comma-separated-values" + private const val MIME_TYPE_KDBX = "application/octet-stream" + + private const val EXTRA_BACKUP_TYPE = "de.davis.passwordmanager.extra.BACKUP_TYPE" + private const val EXTRA_BACKUP_FORMAT = "de.davis.passwordmanager.extra.BACKUP_FORMAT" + + private const val BACKUP_FORMAT_CSV = "type_csv" + private const val BACKUP_FORMAT_KDBX = "type_kdbx" + + private const val DEFAULT_FILE_NAME_CSV = "passwords-keygo.csv" + private const val DEFAULT_FILE_NAME_KDBX = "elements-keygo.kdbx" + } } \ No newline at end of file diff --git a/app/src/main/java/de/davis/passwordmanager/ui/sync/ImportActivity.kt b/app/src/main/java/de/davis/passwordmanager/ui/sync/ImportActivity.kt index d7d7b04e..0a75bc2d 100644 --- a/app/src/main/java/de/davis/passwordmanager/ui/sync/ImportActivity.kt +++ b/app/src/main/java/de/davis/passwordmanager/ui/sync/ImportActivity.kt @@ -7,44 +7,74 @@ import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope -import de.davis.passwordmanager.backup.DataBackup.OnSyncedHandler -import de.davis.passwordmanager.backup.Result -import de.davis.passwordmanager.backup.TYPE_IMPORT -import de.davis.passwordmanager.backup.keygo.KeyGoBackup +import de.davis.passwordmanager.R +import de.davis.passwordmanager.backup.BackupOperation +import de.davis.passwordmanager.backup.BackupResult +import de.davis.passwordmanager.backup.impl.AndroidBackupListener +import de.davis.passwordmanager.backup.impl.AndroidPasswordProvider +import de.davis.passwordmanager.backup.impl.KdbxBackup +import de.davis.passwordmanager.backup.impl.UriBackupResourceProvider import de.davis.passwordmanager.databinding.ActivityImportBinding import de.davis.passwordmanager.ui.MainActivity import de.davis.passwordmanager.ui.auth.AuthenticationRequest import de.davis.passwordmanager.ui.auth.createRequestAuthenticationIntent +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class ImportActivity : AppCompatActivity() { + + private val kdbxBackup = KdbxBackup( + AndroidPasswordProvider(this), + object : AndroidBackupListener(this@ImportActivity) { + + override fun onSuccess( + backupOperation: BackupOperation, + backupResult: BackupResult + ) { + super.onSuccess(backupOperation, backupResult) + startActivity( + Intent( + this@ImportActivity, + MainActivity::class.java + ) + ) + finish() + } + }) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = ActivityImportBinding.inflate( layoutInflater ) setContentView(binding.root) + val fileUri = intent.data ?: run { + finish() + return + } + + val uriBackupResourceProvider = UriBackupResourceProvider(fileUri, contentResolver) + + lifecycleScope.launch(Dispatchers.Main) { + binding.informationView.setInformationText( + getString( + R.string.authenticate_to_import, + uriBackupResourceProvider.getFileName() + ) + ) + } val auth = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> if (result.resultCode != RESULT_OK) return@registerForActivityResult if (intent.action == null) return@registerForActivityResult if (intent.action != Intent.ACTION_VIEW) return@registerForActivityResult - val fileUri = intent.data ?: return@registerForActivityResult - - KeyGoBackup(this).run { - lifecycleScope.launch { - execute(TYPE_IMPORT, fileUri, object : OnSyncedHandler { - override fun onSynced(result: Result?) { - startActivity( - Intent( - this@ImportActivity, - MainActivity::class.java - ) - ) - } - }) - } + + lifecycleScope.launch { + kdbxBackup.execute( + BackupOperation.IMPORT, + UriBackupResourceProvider(fileUri, contentResolver) + ) } } val authIntent = createRequestAuthenticationIntent(AuthenticationRequest.JUST_AUTHENTICATE) diff --git a/app/src/main/res/layout/activity_import.xml b/app/src/main/res/layout/activity_import.xml index a18e6252..cb60eb14 100644 --- a/app/src/main/res/layout/activity_import.xml +++ b/app/src/main/res/layout/activity_import.xml @@ -9,6 +9,7 @@ tools:context=".ui.sync.ImportActivity"> Exportiere oder importiere ein Backup Export CSV Datei - KeyGo Datei - KEYGO Format verwenden + KDBX Datei + KDBX Format verwenden Backup erfolgreich geladen Backup erfolgreich gespeichert Import - Importiere Elemente aus einer verschlüsselten .keygo Datei - Exportiere Elemente in eine .keygo Datei. Diese Datei ist durch ein Passwort geschützt + Importiere Elemente aus einer verschlüsselten .kdbx Datei + Exportiere Elemente in eine .kdbx Datei. Diese Datei ist durch ein Passwort geschützt Importieren Sie Passwörter von Ihrem Browser. Suchen Sie nach Passwortmanager in den Einstellungen Ihres Browsers und exportienen Sie Ihre Passwörter in einer CSV Datei. Exportieren Sie Passwörter in ein CSV-Format, um sie z.B in Ihrem Browser zu importieren. CSV Datei muss 5 Spalten haben. - Bitte beachten Sie, dass dieser Exportprozess keine Verschlüsselung bietet. Wenn nicht autorisierte Dritte Zugriff auf diese Datei erhalten, können sie Ihre Passwörter einsehen. Darüber hinaus ermöglicht diese Option ausschließlich die Exportierung von Passwörtern.\n\nEs wird empfohlen, das KEYGO Dateiformat zu wählen! + Bitte beachten Sie, dass dieser Exportprozess keine Verschlüsselung bietet. Wenn nicht autorisierte Dritte Zugriff auf diese Datei erhalten, können sie Ihre Passwörter einsehen. Darüber hinaus ermöglicht diese Option ausschließlich die Exportierung von Passwörtern.\n\nEs wird empfohlen, das KDBX Dateiformat zu wählen! %d Element wurde ignoriert, da es bereits existiert @@ -149,8 +149,9 @@ Ungültige Datei Das könnte ein wenig Zeit in Anspruch nehmen. Bitte haben Sie Geduld - Um ein Backup laden zu können, ist eine Authentifizierung erforderlich + Um \"%s\" laden zu können, ist eine Authentifizierung erforderlich Authentifizieren um fortzufahren + Geben Sie das Passwort für \"%s\" ein Tag Tags diff --git a/app/src/main/res/values/preferences.xml b/app/src/main/res/values/preferences.xml index 36dd3811..db65b96c 100644 --- a/app/src/main/res/values/preferences.xml +++ b/app/src/main/res/values/preferences.xml @@ -7,6 +7,6 @@ license import_csv export_csv - import_keygo - export_keygo + import_kdbx + export_kdbx \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e725f8ec..61ef3657 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -172,14 +172,14 @@ Import Export CSV File - KeyGo File - Use KEYGO format - Import elements using a encrypted .keygo file - Export elements using a .keygo file. Elements exported using this option will be securely encrypted with a password + KDBX File + Use KDBX format + Import elements using a encrypted .kdbx file + Export elements using a .kdbx file. Elements exported using this option will be securely encrypted with a password Import passwords from your browser. Search for Passwordmanager in your browser\'s Settings and export your passwords as a csv file. Export passwords in a csv format to import them in your browser\'s password manager - Please note that this export process does not provide encryption, and if unauthorized third parties gain access to this file, they will have visibility into your passwords. Furthermore, please be aware that this option allows the export of passwords exclusively.\n\nIt is recommended to choose the KEYGO file format! + Please note that this export process does not provide encryption, and if unauthorized third parties gain access to this file, they will have visibility into your passwords. Furthermore, please be aware that this option allows the export of passwords exclusively.\n\nIt is recommended to choose the KDBX file format! CSV file must have 5 rows. Backup successfully restored Backup successfully stored @@ -191,8 +191,9 @@ Invalid file This might take a little time. Please be patient - To load a backup, authentication is required + To load \"%s\", authentication is required Authenticate to proceed + Enter password for \"%s\" Tag Tags diff --git a/app/src/main/res/xml/backup_preferences.xml b/app/src/main/res/xml/backup_preferences.xml index 2ae881a0..c2a41123 100644 --- a/app/src/main/res/xml/backup_preferences.xml +++ b/app/src/main/res/xml/backup_preferences.xml @@ -1,34 +1,29 @@ - + + android:key="@string/preference_import_kdbx" + android:title="@string/kdbx_file" + android:summary="@string/kdbx_summary_import" /> - + android:summary="@string/csv_summary_import"/> - + - + android:key="@string/preference_export_kdbx" + android:title="@string/kdbx_file" + android:summary="@string/kdbx_summary_export"/> - + android:summary="@string/csv_summary_export"/> \ No newline at end of file