Skip to content

Commit

Permalink
Display a descriptive error when import fails (#122)
Browse files Browse the repository at this point in the history
  • Loading branch information
SaintPatrck authored Jun 12, 2024
1 parent f695254 commit 2640d28
Show file tree
Hide file tree
Showing 12 changed files with 114 additions and 72 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -250,10 +250,16 @@ class AuthenticatorRepositoryImpl @Inject constructor(
format: ImportFileFormat,
fileData: IntentManager.FileData,
): ImportDataResult = fileManager.uriToByteArray(fileData.uri)
.map { importManager.import(importFileFormat = format, byteArray = it) }
.map {
importManager
.import(
importFileFormat = format,
byteArray = it
)
}
.fold(
onSuccess = { it },
onFailure = { ImportDataResult.Error }
onFailure = { ImportDataResult.Error() }
)

private suspend fun encodeVaultDataToCsv(fileUri: Uri): ExportDataResult {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.bitwarden.authenticator.data.platform.manager.imports

import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource
import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult
import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportDataResult
import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat
import com.bitwarden.authenticator.data.platform.manager.imports.parsers.AegisExportParser
Expand Down Expand Up @@ -29,14 +30,18 @@ class ImportManagerImpl(
}

return try {
parser.parse(byteArray)
.mapCatching { authenticatorDiskSource.saveItem(*it.toTypedArray()) }
.fold(
onSuccess = { ImportDataResult.Success },
onFailure = { ImportDataResult.Error }
)
when (val parseResult = parser.parse(byteArray)) {
is ExportParseResult.Error -> {
ImportDataResult.Error(parseResult.message)
}

is ExportParseResult.Success -> {
authenticatorDiskSource.saveItem(*parseResult.items.toTypedArray())
ImportDataResult.Success
}
}
} catch (e: Throwable) {
ImportDataResult.Error
ImportDataResult.Error()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.bitwarden.authenticator.data.platform.manager.imports.model

import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
import com.bitwarden.authenticator.ui.platform.base.util.Text

/**
* Represents the result of parsing an export file.
*/
sealed class ExportParseResult {

/**
* Indicates the selected file has been successfully parsed.
*/
data class Success(val items: List<AuthenticatorItemEntity>) : ExportParseResult()

/**
* Indicates there was an error while parsing the selected file.
*
* @property message User friendly message displayed to the user, if provided.
*/
data class Error(val message: Text? = null) : ExportParseResult()
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.bitwarden.authenticator.data.platform.manager.imports.model

import com.bitwarden.authenticator.ui.platform.base.util.Text

/**
* Represents the result of a data import operation.
*/
sealed class ImportDataResult {
data object Success : ImportDataResult()

data object Error : ImportDataResult()
data class Error(val message: Text? = null) : ImportDataResult()
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ data class TwoFasJsonExport(
val appVersionCode: Int,
val appOrigin: String,
val services: List<Service>,
val servicesEncrypted: String?,
val groups: List<Group>,
) {
@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.Aut
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType
import com.bitwarden.authenticator.data.platform.manager.imports.model.AegisJsonExport
import com.bitwarden.authenticator.data.platform.util.asFailure
import com.bitwarden.authenticator.data.platform.util.asSuccess
import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
Expand All @@ -16,29 +15,28 @@ import java.util.UUID

class AegisExportParser : ExportParser {
@OptIn(ExperimentalSerializationApi::class)
override fun parse(byteArray: ByteArray): Result<List<AuthenticatorItemEntity>> {
override fun parse(byteArray: ByteArray): ExportParseResult {
val importJson = Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
}

return try {
importJson
val exportData = importJson
.decodeFromStream<AegisJsonExport>(ByteArrayInputStream(byteArray))
.asSuccess()
.mapCatching { exportData ->
exportData
.db
.entries
.toAuthenticatorItemEntities()
}
ExportParseResult.Success(
items = exportData
.db
.entries
.toAuthenticatorItemEntities(),
)
} catch (e: SerializationException) {
e.asFailure()
ExportParseResult.Error()
} catch (e: IllegalArgumentException) {
e.asFailure()
ExportParseResult.Error()
} catch (e: IOException) {
e.asFailure()
ExportParseResult.Error()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package com.bitwarden.authenticator.data.platform.manager.imports.parsers

import android.net.Uri
import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType
import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager
import com.bitwarden.authenticator.data.authenticator.manager.model.ExportJsonData
import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult
import com.bitwarden.authenticator.data.platform.manager.imports.model.ImportFileFormat
import com.bitwarden.authenticator.data.platform.util.asFailure
import com.bitwarden.authenticator.data.platform.util.asSuccess
import com.bitwarden.authenticator.ui.platform.base.util.asText
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
Expand All @@ -19,37 +20,36 @@ import java.io.IOException
class BitwardenExportParser(
private val fileFormat: ImportFileFormat,
) : ExportParser {
override fun parse(byteArray: ByteArray): Result<List<AuthenticatorItemEntity>> {
override fun parse(byteArray: ByteArray): ExportParseResult {
return when (fileFormat) {
ImportFileFormat.BITWARDEN_JSON -> importJsonFile(byteArray)
else -> IllegalArgumentException("Unsupported file format.").asFailure()
else -> ExportParseResult.Error(R.string.import_bitwarden_unsupported_format.asText())
}
}

@OptIn(ExperimentalSerializationApi::class)
private fun importJsonFile(byteArray: ByteArray): Result<List<AuthenticatorItemEntity>> {
private fun importJsonFile(byteArray: ByteArray): ExportParseResult {
val importJson = Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
}

return try {
importJson
val exportData = importJson
.decodeFromStream<ExportJsonData>(ByteArrayInputStream(byteArray))
.asSuccess()
.mapCatching { exportData ->
exportData
.items
.filter { it.login?.totp != null }
.toAuthenticatorItemEntities()
}
ExportParseResult.Success(
items = exportData
.items
.filter { it.login?.totp != null }
.toAuthenticatorItemEntities()
)
} catch (e: SerializationException) {
e.asFailure()
ExportParseResult.Error()
} catch (e: IllegalArgumentException) {
e.asFailure()
ExportParseResult.Error()
} catch (e: IOException) {
e.asFailure()
ExportParseResult.Error()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.bitwarden.authenticator.data.platform.manager.imports.parsers

import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult

/**
* Responsible for transforming exported authenticator data to a format consumable by this
Expand All @@ -12,5 +13,5 @@ interface ExportParser {
* Converts the given [byteArray] content of a file to a collection of
* [AuthenticatorItemEntity].
*/
fun parse(byteArray: ByteArray): Result<List<AuthenticatorItemEntity>>
fun parse(byteArray: ByteArray): ExportParseResult
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ package com.bitwarden.authenticator.data.platform.manager.imports.parsers
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType
import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult
import com.bitwarden.authenticator.data.platform.manager.imports.model.LastPassJsonExport
import com.bitwarden.authenticator.data.platform.util.asFailure
import com.bitwarden.authenticator.data.platform.util.asSuccess
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
Expand All @@ -21,28 +20,27 @@ import java.util.UUID
class LastPassExportParser : ExportParser {

@OptIn(ExperimentalSerializationApi::class)
override fun parse(byteArray: ByteArray): Result<List<AuthenticatorItemEntity>> {
override fun parse(byteArray: ByteArray): ExportParseResult {
val importJson = Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
}

return try {
importJson
.decodeFromStream<LastPassJsonExport>(ByteArrayInputStream(byteArray))
.asSuccess()
.mapCatching { exportData ->
exportData
.accounts
.toAuthenticatorItemEntities()
}
val exportData =
importJson.decodeFromStream<LastPassJsonExport>(ByteArrayInputStream(byteArray))
ExportParseResult.Success(
items = exportData
.accounts
.toAuthenticatorItemEntities(),
)
} catch (e: SerializationException) {
e.asFailure()
ExportParseResult.Error()
} catch (e: IllegalArgumentException) {
e.asFailure()
ExportParseResult.Error()
} catch (e: IOException) {
e.asFailure()
ExportParseResult.Error()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package com.bitwarden.authenticator.data.platform.manager.imports.parsers

import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemAlgorithm
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity
import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType
import com.bitwarden.authenticator.data.platform.manager.imports.model.ExportParseResult
import com.bitwarden.authenticator.data.platform.manager.imports.model.TwoFasJsonExport
import com.bitwarden.authenticator.data.platform.util.asFailure
import com.bitwarden.authenticator.data.platform.util.asSuccess
import com.bitwarden.authenticator.ui.platform.base.util.asText
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
Expand All @@ -21,33 +22,37 @@ private const val TOKEN_TYPE_HOTP = "HOTP"
* items.
*/
class TwoFasExportParser : ExportParser {
override fun parse(byteArray: ByteArray): Result<List<AuthenticatorItemEntity>> {
override fun parse(byteArray: ByteArray): ExportParseResult {
return import2fasJson(byteArray)
}

@OptIn(ExperimentalSerializationApi::class)
private fun import2fasJson(byteArray: ByteArray): Result<List<AuthenticatorItemEntity>> {
private fun import2fasJson(byteArray: ByteArray): ExportParseResult {
val importJson = Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
}

return try {
importJson
val exportData = importJson
.decodeFromStream<TwoFasJsonExport>(ByteArrayInputStream(byteArray))
.asSuccess()
.mapCatching { exportData ->
exportData
.services
.toAuthenticatorItemEntities()
}

if (!exportData.servicesEncrypted.isNullOrEmpty()) {
ExportParseResult.Error(
message = R.string.import_2fas_password_protected_not_supported.asText(),
)
} else {
ExportParseResult.Success(
items = exportData.services.toAuthenticatorItemEntities()
)
}
} catch (e: SerializationException) {
e.asFailure()
ExportParseResult.Error()
} catch (e: IllegalArgumentException) {
e.asFailure()
ExportParseResult.Error()
} catch (e: IOException) {
e.asFailure()
ExportParseResult.Error()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ class ImportingViewModel @Inject constructor(
private fun handleImportLocationReceive(action: ImportAction.ImportLocationReceive) {
mutableStateFlow.update { it.copy(dialogState = ImportState.DialogState.Loading()) }
viewModelScope.launch {
val result = authenticatorRepository.importVaultData(state.importFileFormat, action.fileUri)
val result =
authenticatorRepository.importVaultData(state.importFileFormat, action.fileUri)
sendAction(ImportAction.Internal.SaveImportDataToUriResultReceive(result))
}
}
Expand All @@ -89,12 +90,13 @@ class ImportingViewModel @Inject constructor(

private fun handleSaveImportDataToUriResultReceive(result: ImportDataResult) {
when (result) {
ImportDataResult.Error -> {
is ImportDataResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = ImportState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.import_vault_failure.asText(),
message = result.message
?: R.string.import_vault_failure.asText(),
)
)
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,6 @@
<string name="data_backup_title">Data backup</string>
<string name="data_backup_message">Bitwarden Authenticator data is backed up and can be restored with your regularly scheduled device backups.</string>
<string name="learn_more">Learn more</string>
<string name="import_2fas_password_protected_not_supported">Importing from 2FAS password protected files is not supported. Try again with an exported file that is not password protected.</string>
<string name="import_bitwarden_unsupported_format">Importing Bitwarden CSV files is not supported. Try again with an exported JSON file.</string>
</resources>

0 comments on commit 2640d28

Please sign in to comment.