improvement: Show actual JSON when a cipher fails the decoding #224

This commit is contained in:
Artem Chepurnoy 2024-03-27 17:20:21 +02:00
parent 1d170fdaeb
commit a1210b4ef8
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
7 changed files with 177 additions and 78 deletions

View File

@ -31,6 +31,7 @@ data class BitwardenService(
data class Error( data class Error(
val code: Int, val code: Int,
val message: String? = null, val message: String? = null,
val blob: String? = null,
val revisionDate: Instant, val revisionDate: Instant,
) { ) {
companion object { companion object {

View File

@ -1,8 +1,12 @@
package com.artemchep.keyguard.feature.home.vault.component package com.artemchep.keyguard.feature.home.vault.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
@ -11,6 +15,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccessTime import androidx.compose.material.icons.outlined.AccessTime
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -24,7 +29,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.artemchep.keyguard.feature.home.vault.model.VaultViewItem import com.artemchep.keyguard.feature.home.vault.model.VaultViewItem
import com.artemchep.keyguard.ui.DisabledEmphasisAlpha import com.artemchep.keyguard.ui.DisabledEmphasisAlpha
import com.artemchep.keyguard.ui.ExpandedIfNotEmpty import com.artemchep.keyguard.ui.ExpandedIfNotEmpty
@ -122,6 +131,83 @@ fun VaultViewErrorItem(
.width(8.dp), .width(8.dp),
) )
} }
if (item.blob != null) Row(
modifier = Modifier
.padding(vertical = 8.dp)
.height(IntrinsicSize.Min)
.background(
color = LocalContentColor.current
.combineAlpha(0.1f),
),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(
modifier = Modifier
.width(8.dp),
)
Box(
modifier = Modifier
.width(8.dp)
.background(
color = LocalContentColor.current
.combineAlpha(MediumEmphasisAlpha),
)
.fillMaxHeight(),
)
Spacer(
modifier = Modifier
.width(8.dp),
)
Column(
modifier = Modifier
.weight(1f),
) {
Text(
modifier = modifier
.padding(
vertical = 8.dp,
),
text = "BLOB",
style = MaterialTheme.typography.labelLarge,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = LocalContentColor.current
.combineAlpha(MediumEmphasisAlpha),
)
Text(
modifier = Modifier
.padding(),
text = item.blob,
maxLines = 3,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
overflow = TextOverflow.Ellipsis,
)
Spacer(
modifier = Modifier
.height(8.dp),
)
}
Spacer(
modifier = Modifier
.width(8.dp),
)
val updatedOnCopy by rememberUpdatedState(item.onCopyBlob)
IconButton(
onClick = {
updatedOnCopy?.invoke()
},
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = null,
)
}
Spacer(
modifier = Modifier
.width(8.dp),
)
}
HorizontalDivider() HorizontalDivider()
Row( Row(
modifier = Modifier modifier = Modifier

View File

@ -111,8 +111,10 @@ sealed interface VaultViewItem {
override val id: String, override val id: String,
val name: String, val name: String,
val message: String?, val message: String?,
val blob: String? = null,
val timestamp: String, val timestamp: String,
val onRetry: (() -> Unit)? = null, val onRetry: (() -> Unit)? = null,
val onCopyBlob: (() -> Unit)? = null,
) : VaultViewItem { ) : VaultViewItem {
companion object; companion object;
} }

View File

@ -819,6 +819,7 @@ private fun RememberStateFlowScope.oh(
id = "error", id = "error",
name = "Couldn't sync the item", name = "Couldn't sync the item",
message = cipherError.message(), message = cipherError.message(),
blob = cipherError.blob,
timestamp = time, timestamp = time,
onRetry = if (cipherError.canRetry(cipher.revisionDate)) { onRetry = if (cipherError.canRetry(cipher.revisionDate)) {
// lambda // lambda
@ -830,6 +831,17 @@ private fun RememberStateFlowScope.oh(
} else { } else {
null null
}, },
onCopyBlob = if (cipherError.blob != null) {
// lambda
{
copy.copy(
text = cipherError.blob,
hidden = false,
)
}
} else {
null
},
) )
emit(model) emit(model)
} }

View File

@ -58,6 +58,8 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.NoTransformationFoundException import io.ktor.client.call.NoTransformationFoundException
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.RuntimeException import kotlin.RuntimeException
import kotlin.String import kotlin.String
import kotlin.TODO import kotlin.TODO
@ -73,6 +75,7 @@ import kotlin.to
class SyncEngine( class SyncEngine(
private val httpClient: HttpClient, private val httpClient: HttpClient,
private val dbManager: DatabaseManager, private val dbManager: DatabaseManager,
private val json: Json,
private val base64Service: Base64Service, private val base64Service: Base64Service,
private val cryptoGenerator: CryptoGenerator, private val cryptoGenerator: CryptoGenerator,
private val cipherEncryptor: CipherEncryptor, private val cipherEncryptor: CipherEncryptor,
@ -89,6 +92,32 @@ class SyncEngine(
class DecodeVaultException(message: String, e: Throwable) : RuntimeException(message, e) class DecodeVaultException(message: String, e: Throwable) : RuntimeException(message, e)
private inline fun <reified T> createDecodingFailedServiceModel(
now: Instant,
model: T,
lens: SyncManager.Lens<T>,
): BitwardenService {
val revisionDate = lens.getRevisionDate(model)
.takeUnless { it == Instant.DISTANT_FUTURE }
?: now
val blob = json.encodeToString(model)
return BitwardenService(
remote = BitwardenService.Remote(
id = lens.getId(model),
revisionDate = revisionDate,
deletedDate = lens.getDeletedDate(model),
),
error = BitwardenService.Error(
code = BitwardenService.Error.CODE_DECODING_FAILED,
blob = blob,
revisionDate = now,
),
deleted = false,
version = BitwardenService.VERSION,
)
}
suspend fun sync() = kotlin.run { suspend fun sync() = kotlin.run {
val env = user.env.back() val env = user.env.back()
val api = env.api val api = env.api
@ -264,6 +293,10 @@ class SyncEngine(
} }
val folderDao = db.folderQueries val folderDao = db.folderQueries
val folderRemoteLens = SyncManager.Lens<FolderEntity>(
getId = { it.id },
getRevisionDate = { it.revisionDate },
)
val existingFolders = folderDao val existingFolders = folderDao
.getByAccountId( .getByAccountId(
accountId = user.id, accountId = user.id,
@ -345,18 +378,10 @@ class SyncEngine(
val localId = localOrNull?.folderId val localId = localOrNull?.folderId
?: cryptoGenerator.uuid() ?: cryptoGenerator.uuid()
val service = BitwardenService( val service = createDecodingFailedServiceModel(
remote = BitwardenService.Remote( now = now,
id = remote.id, model = remote,
revisionDate = remote.revisionDate, lens = folderRemoteLens,
deletedDate = null,
),
error = BitwardenService.Error(
code = BitwardenService.Error.CODE_DECODING_FAILED,
revisionDate = now,
),
deleted = false,
version = BitwardenService.VERSION,
) )
val model = localOrNull?.copy(service = service) ?: BitwardenFolder( val model = localOrNull?.copy(service = service) ?: BitwardenFolder(
accountId = user.id, accountId = user.id,
@ -457,6 +482,11 @@ class SyncEngine(
} }
val cipherDao = db.cipherQueries val cipherDao = db.cipherQueries
val cipherRemoteLens = SyncManager.Lens<CipherEntity>(
getId = { it.id },
getRevisionDate = { cipher -> cipher.revisionDate },
getDeletedDate = { cipher -> cipher.deletedDate },
)
val existingCipher = cipherDao val existingCipher = cipherDao
.getByAccountId( .getByAccountId(
accountId = user.id, accountId = user.id,
@ -530,11 +560,7 @@ class SyncEngine(
false false
}, },
remoteItems = response.ciphers.orEmpty(), remoteItems = response.ciphers.orEmpty(),
remoteLens = SyncManager.Lens( remoteLens = cipherRemoteLens,
getId = { it.id },
getRevisionDate = { cipher -> cipher.revisionDate },
getDeletedDate = { cipher -> cipher.deletedDate },
),
remoteDecoder = { remote, local -> remoteDecoder = { remote, local ->
val codec = getCodec( val codec = getCodec(
mode = BitwardenCrCta.Mode.DECRYPT, mode = BitwardenCrCta.Mode.DECRYPT,
@ -571,18 +597,10 @@ class SyncEngine(
val localId = localOrNull?.cipherId val localId = localOrNull?.cipherId
?: cryptoGenerator.uuid() ?: cryptoGenerator.uuid()
val folderId = remote.folderId?.let { remoteToLocalFolders[it] } val folderId = remote.folderId?.let { remoteToLocalFolders[it] }
val service = BitwardenService( val service = createDecodingFailedServiceModel(
remote = BitwardenService.Remote( now = now,
id = remote.id, model = remote,
revisionDate = remote.revisionDate, lens = cipherRemoteLens,
deletedDate = remote.deletedDate,
),
error = BitwardenService.Error(
code = BitwardenService.Error.CODE_DECODING_FAILED,
revisionDate = now,
),
deleted = false,
version = BitwardenService.VERSION,
) )
val model = localOrNull?.copy(service = service) ?: BitwardenCipher( val model = localOrNull?.copy(service = service) ?: BitwardenCipher(
accountId = user.id, accountId = user.id,
@ -732,6 +750,10 @@ class SyncEngine(
} }
val collectionDao = db.collectionQueries val collectionDao = db.collectionQueries
val collectionRemoteLens = SyncManager.Lens<CollectionEntity>(
getId = { it.id },
getRevisionDate = { Instant.DISTANT_FUTURE },
)
val existingCollections = collectionDao val existingCollections = collectionDao
.getByAccountId( .getByAccountId(
accountId = user.id, accountId = user.id,
@ -772,10 +794,7 @@ class SyncEngine(
} }
}, },
remoteItems = response.collections.orEmpty(), remoteItems = response.collections.orEmpty(),
remoteLens = SyncManager.Lens<CollectionEntity>( remoteLens = collectionRemoteLens,
getId = { it.id },
getRevisionDate = { Instant.DISTANT_FUTURE },
),
remoteDecoder = { remote, local -> remoteDecoder = { remote, local ->
val codec = getCodec( val codec = getCodec(
mode = BitwardenCrCta.Mode.DECRYPT, mode = BitwardenCrCta.Mode.DECRYPT,
@ -796,18 +815,10 @@ class SyncEngine(
) )
recordException(logE) recordException(logE)
val service = BitwardenService( val service = createDecodingFailedServiceModel(
remote = BitwardenService.Remote( now = now,
id = remote.id, model = remote,
revisionDate = now, lens = collectionRemoteLens,
deletedDate = null,
),
error = BitwardenService.Error(
code = BitwardenService.Error.CODE_DECODING_FAILED,
revisionDate = now,
),
deleted = false,
version = BitwardenService.VERSION,
) )
val model = localOrNull?.copy(service = service) ?: BitwardenCollection( val model = localOrNull?.copy(service = service) ?: BitwardenCollection(
accountId = user.id, accountId = user.id,
@ -848,6 +859,10 @@ class SyncEngine(
} }
val organizationDao = db.organizationQueries val organizationDao = db.organizationQueries
val organizationRemoteLens = SyncManager.Lens<OrganizationEntity>(
getId = { it.id },
getRevisionDate = { Instant.DISTANT_FUTURE },
)
val existingOrganizations = organizationDao val existingOrganizations = organizationDao
.getByAccountId( .getByAccountId(
accountId = user.id, accountId = user.id,
@ -888,10 +903,7 @@ class SyncEngine(
} }
}, },
remoteItems = response.profile.organizations.orEmpty(), remoteItems = response.profile.organizations.orEmpty(),
remoteLens = SyncManager.Lens<OrganizationEntity>( remoteLens = organizationRemoteLens,
getId = { it.id },
getRevisionDate = { Instant.DISTANT_FUTURE },
),
remoteDecoder = { remote, local -> remoteDecoder = { remote, local ->
val codec = getCodec( val codec = getCodec(
mode = BitwardenCrCta.Mode.DECRYPT, mode = BitwardenCrCta.Mode.DECRYPT,
@ -911,18 +923,10 @@ class SyncEngine(
) )
recordException(logE) recordException(logE)
val service = BitwardenService( val service = createDecodingFailedServiceModel(
remote = BitwardenService.Remote( now = now,
id = remote.id, model = remote,
revisionDate = now, lens = organizationRemoteLens,
deletedDate = null,
),
error = BitwardenService.Error(
code = BitwardenService.Error.CODE_DECODING_FAILED,
revisionDate = now,
),
deleted = false,
version = BitwardenService.VERSION,
) )
val model = localOrNull?.copy(service = service) ?: BitwardenOrganization( val model = localOrNull?.copy(service = service) ?: BitwardenOrganization(
accountId = user.id, accountId = user.id,
@ -965,6 +969,10 @@ class SyncEngine(
} }
val sendDao = db.sendQueries val sendDao = db.sendQueries
val sendRemoteLens = SyncManager.Lens<SyncSends>(
getId = { it.id },
getRevisionDate = { it.revisionDate },
)
val existingSends = sendDao val existingSends = sendDao
.getByAccountId( .getByAccountId(
accountId = user.id, accountId = user.id,
@ -1025,10 +1033,7 @@ class SyncEngine(
} }
}, },
remoteItems = response.sends.orEmpty(), remoteItems = response.sends.orEmpty(),
remoteLens = SyncManager.Lens<SyncSends>( remoteLens = sendRemoteLens,
getId = { it.id },
getRevisionDate = { it.revisionDate },
),
remoteDecoder = { remote, local -> remoteDecoder = { remote, local ->
val ( val (
itemCrypto, itemCrypto,
@ -1053,18 +1058,10 @@ class SyncEngine(
}, },
remoteDecodedFallback = { remote, localOrNull, e -> remoteDecodedFallback = { remote, localOrNull, e ->
e.printStackTrace() e.printStackTrace()
val service = BitwardenService( val service = createDecodingFailedServiceModel(
remote = BitwardenService.Remote( now = now,
id = remote.id, model = remote,
revisionDate = remote.revisionDate, lens = sendRemoteLens,
deletedDate = null,
),
error = BitwardenService.Error(
code = BitwardenService.Error.CODE_DECODING_FAILED,
revisionDate = now,
),
deleted = false,
version = BitwardenService.VERSION,
) )
val model = localOrNull?.copy(service = service) ?: BitwardenSend( val model = localOrNull?.copy(service = service) ?: BitwardenSend(
accountId = user.id, accountId = user.id,

View File

@ -10,7 +10,7 @@ class SyncManager<Local : BitwardenService.Has<Local>, Remote : Any>(
private val local: LensLocal<Local>, private val local: LensLocal<Local>,
private val remote: Lens<Remote>, private val remote: Lens<Remote>,
) { ) {
class Lens<T>( data class Lens<T>(
val getId: (T) -> String, val getId: (T) -> String,
val getRevisionDate: (T) -> Instant, val getRevisionDate: (T) -> Instant,
val getDeletedDate: (T) -> Instant? = { null }, val getDeletedDate: (T) -> Instant? = { null },

View File

@ -82,6 +82,7 @@ class SyncByTokenImpl(
val syncEngine = SyncEngine( val syncEngine = SyncEngine(
httpClient = httpClient, httpClient = httpClient,
dbManager = db, dbManager = db,
json = json,
base64Service = base64Service, base64Service = base64Service,
cryptoGenerator = cryptoGenerator, cryptoGenerator = cryptoGenerator,
cipherEncryptor = cipherEncryptor, cipherEncryptor = cipherEncryptor,