Metadata encryption keys (#1781)

* Metadata encryption keys

* Decryption hotfix

* Transaction detail bottom bar design hotfix

* Auto backup enabled for metadata
This commit is contained in:
Milan 2025-02-28 15:20:21 +01:00 committed by GitHub
parent 13695bbd97
commit 2c37d086f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 479 additions and 329 deletions

View File

@ -3,4 +3,7 @@
<include <include
domain="file" domain="file"
path="address_book/." /> path="address_book/." />
<include
domain="file"
path="metadata/." />
</full-backup-content> </full-backup-content>

View File

@ -4,5 +4,8 @@
<include <include
domain="file" domain="file"
path="address_book/." /> path="address_book/." />
<include
domain="file"
path="metadata/." />
</cloud-backup> </cloud-backup>
</data-extraction-rules> </data-extraction-rules>

View File

@ -11,6 +11,11 @@ interface PreferenceProvider {
value: String? value: String?
) )
suspend fun putStringSet(
key: PreferenceKey,
value: Set<String>?
)
suspend fun putLong( suspend fun putLong(
key: PreferenceKey, key: PreferenceKey,
value: Long? value: Long?
@ -20,6 +25,8 @@ interface PreferenceProvider {
suspend fun getString(key: PreferenceKey): String? suspend fun getString(key: PreferenceKey): String?
suspend fun getStringSet(key: PreferenceKey): Set<String>?
fun observe(key: PreferenceKey): Flow<String?> fun observe(key: PreferenceKey): Flow<String?>
suspend fun clearPreferences(): Boolean suspend fun clearPreferences(): Boolean

View File

@ -15,6 +15,10 @@ class MockPreferenceProvider(
override suspend fun getString(key: PreferenceKey) = map[key.key] override suspend fun getString(key: PreferenceKey) = map[key.key]
override suspend fun getStringSet(key: PreferenceKey): Set<String>? {
TODO("Not yet implemented")
}
// For the mock implementation, does not support observability of changes // For the mock implementation, does not support observability of changes
override fun observe(key: PreferenceKey): Flow<String?> = flow { emit(getString(key)) } override fun observe(key: PreferenceKey): Flow<String?> = flow { emit(getString(key)) }
@ -32,6 +36,13 @@ class MockPreferenceProvider(
map[key.key] = value map[key.key] = value
} }
override suspend fun putStringSet(
key: PreferenceKey,
value: Set<String>?
) {
TODO("Not yet implemented")
}
override suspend fun putLong( override suspend fun putLong(
key: PreferenceKey, key: PreferenceKey,
value: Long? value: Long?

View File

@ -59,6 +59,22 @@ class AndroidPreferenceProvider private constructor(
} }
} }
@SuppressLint("ApplySharedPref")
override suspend fun putStringSet(
key: PreferenceKey,
value: Set<String>?
) = withContext(dispatcher) {
mutex.withLock {
val editor = sharedPreferences.edit()
editor.putStringSet(key.key, value)
editor.commit()
Unit
}
}
@SuppressLint("ApplySharedPref") @SuppressLint("ApplySharedPref")
override suspend fun putLong( override suspend fun putLong(
key: PreferenceKey, key: PreferenceKey,
@ -91,6 +107,11 @@ class AndroidPreferenceProvider private constructor(
sharedPreferences.getString(key.key, null) sharedPreferences.getString(key.key, null)
} }
override suspend fun getStringSet(key: PreferenceKey): Set<String>? =
withContext(dispatcher) {
sharedPreferences.getStringSet(key.key, null)
}
@SuppressLint("ApplySharedPref") @SuppressLint("ApplySharedPref")
override suspend fun clearPreferences() = override suspend fun clearPreferences() =
withContext(dispatcher) { withContext(dispatcher) {

View File

@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -22,6 +23,7 @@ import co.electriccoin.zcash.ui.design.util.stringRes
fun ZashiBottomBar( fun ZashiBottomBar(
isElevated: Boolean, isElevated: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
Surface( Surface(
@ -29,7 +31,9 @@ fun ZashiBottomBar(
color = ZashiColors.Surfaces.bgPrimary, color = ZashiColors.Surfaces.bgPrimary,
modifier = modifier, modifier = modifier,
) { ) {
Column { Column(
modifier = Modifier.padding(contentPadding),
) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
content() content()
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))

View File

@ -36,13 +36,17 @@ fun Modifier.scaffoldScrollPadding(
) )
@Stable @Stable
fun PaddingValues.asScaffoldPaddingValues() = fun PaddingValues.asScaffoldPaddingValues(
PaddingValues( top: Dp = calculateTopPadding() + ZashiDimensions.Spacing.spacingLg,
top = calculateTopPadding() + ZashiDimensions.Spacing.spacingLg, bottom: Dp = calculateBottomPadding() + ZashiDimensions.Spacing.spacing3xl,
bottom = calculateBottomPadding() + ZashiDimensions.Spacing.spacing3xl, start: Dp = ZashiDimensions.Spacing.spacing3xl,
start = ZashiDimensions.Spacing.spacing3xl, end: Dp = ZashiDimensions.Spacing.spacing3xl
end = ZashiDimensions.Spacing.spacing3xl ) = PaddingValues(
) top = top,
bottom = bottom,
start = start,
end = end,
)
@Stable @Stable
fun PaddingValues.asScaffoldScrollPaddingValues( fun PaddingValues.asScaffoldScrollPaddingValues(

View File

@ -1,6 +1,5 @@
package co.electriccoin.zcash.ui.common.datasource package co.electriccoin.zcash.ui.common.datasource
import cash.z.ecc.android.sdk.model.AccountUuid
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.model.AccountMetadata import co.electriccoin.zcash.ui.common.model.AccountMetadata
import co.electriccoin.zcash.ui.common.model.AnnotationMetadata import co.electriccoin.zcash.ui.common.model.AnnotationMetadata
@ -8,6 +7,7 @@ import co.electriccoin.zcash.ui.common.model.BookmarkMetadata
import co.electriccoin.zcash.ui.common.model.Metadata import co.electriccoin.zcash.ui.common.model.Metadata
import co.electriccoin.zcash.ui.common.provider.MetadataProvider import co.electriccoin.zcash.ui.common.provider.MetadataProvider
import co.electriccoin.zcash.ui.common.provider.MetadataStorageProvider import co.electriccoin.zcash.ui.common.provider.MetadataStorageProvider
import co.electriccoin.zcash.ui.common.serialization.METADATA_SERIALIZATION_V1
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -20,26 +20,22 @@ interface MetadataDataSource {
suspend fun flipTxAsBookmarked( suspend fun flipTxAsBookmarked(
txId: String, txId: String,
account: AccountUuid,
key: MetadataKey key: MetadataKey
): Metadata ): Metadata
suspend fun createOrUpdateTxNote( suspend fun createOrUpdateTxNote(
txId: String, txId: String,
note: String, note: String,
account: AccountUuid,
key: MetadataKey key: MetadataKey
): Metadata ): Metadata
suspend fun deleteTxNote( suspend fun deleteTxNote(
txId: String, txId: String,
account: AccountUuid,
key: MetadataKey key: MetadataKey
): Metadata ): Metadata
suspend fun markTxMemoAsRead( suspend fun markTxMemoAsRead(
txId: String, txId: String,
account: AccountUuid,
key: MetadataKey key: MetadataKey
): Metadata ): Metadata
@ -47,8 +43,6 @@ interface MetadataDataSource {
metadata: Metadata, metadata: Metadata,
key: MetadataKey key: MetadataKey
) )
suspend fun resetMetadata()
} }
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
@ -56,8 +50,6 @@ class MetadataDataSourceImpl(
private val metadataStorageProvider: MetadataStorageProvider, private val metadataStorageProvider: MetadataStorageProvider,
private val metadataProvider: MetadataProvider, private val metadataProvider: MetadataProvider,
) : MetadataDataSource { ) : MetadataDataSource {
private var metadata: Metadata? = null
private val mutex = Mutex() private val mutex = Mutex()
override suspend fun getMetadata(key: MetadataKey): Metadata = override suspend fun getMetadata(key: MetadataKey): Metadata =
@ -67,11 +59,10 @@ class MetadataDataSourceImpl(
override suspend fun flipTxAsBookmarked( override suspend fun flipTxAsBookmarked(
txId: String, txId: String,
account: AccountUuid,
key: MetadataKey, key: MetadataKey,
): Metadata = ): Metadata =
mutex.withLock { mutex.withLock {
updateMetadataBookmark(txId = txId, account = account, key = key) { updateMetadataBookmark(txId = txId, key = key) {
it.copy( it.copy(
isBookmarked = !it.isBookmarked, isBookmarked = !it.isBookmarked,
lastUpdated = Instant.now(), lastUpdated = Instant.now(),
@ -82,13 +73,11 @@ class MetadataDataSourceImpl(
override suspend fun createOrUpdateTxNote( override suspend fun createOrUpdateTxNote(
txId: String, txId: String,
note: String, note: String,
account: AccountUuid,
key: MetadataKey key: MetadataKey
): Metadata = ): Metadata =
mutex.withLock { mutex.withLock {
updateMetadataAnnotation( updateMetadataAnnotation(
txId = txId, txId = txId,
account = account,
key = key key = key
) { ) {
it.copy( it.copy(
@ -100,13 +89,11 @@ class MetadataDataSourceImpl(
override suspend fun deleteTxNote( override suspend fun deleteTxNote(
txId: String, txId: String,
account: AccountUuid,
key: MetadataKey key: MetadataKey
): Metadata = ): Metadata =
mutex.withLock { mutex.withLock {
updateMetadataAnnotation( updateMetadataAnnotation(
txId = txId, txId = txId,
account = account,
key = key key = key
) { ) {
it.copy( it.copy(
@ -118,12 +105,10 @@ class MetadataDataSourceImpl(
override suspend fun markTxMemoAsRead( override suspend fun markTxMemoAsRead(
txId: String, txId: String,
account: AccountUuid,
key: MetadataKey key: MetadataKey
): Metadata = ): Metadata =
mutex.withLock { mutex.withLock {
updateMetadata( updateMetadata(
account = account,
key = key, key = key,
transform = { metadata -> transform = { metadata ->
metadata.copy( metadata.copy(
@ -138,14 +123,8 @@ class MetadataDataSourceImpl(
key: MetadataKey key: MetadataKey
) = mutex.withLock { ) = mutex.withLock {
writeToLocalStorage(metadata, key) writeToLocalStorage(metadata, key)
this.metadata = metadata
} }
override suspend fun resetMetadata() =
mutex.withLock {
metadata = null
}
private suspend fun getMetadataInternal(key: MetadataKey): Metadata { private suspend fun getMetadataInternal(key: MetadataKey): Metadata {
fun readLocalFileToMetadata(key: MetadataKey): Metadata? { fun readLocalFileToMetadata(key: MetadataKey): Metadata? {
val encryptedFile = val encryptedFile =
@ -158,46 +137,38 @@ class MetadataDataSourceImpl(
} }
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val inMemory = metadata var new: Metadata? = readLocalFileToMetadata(key)
if (new == null) {
if (inMemory == null) { new =
var new: Metadata? = readLocalFileToMetadata(key) Metadata(
if (new == null) { version = METADATA_SERIALIZATION_V1,
new = lastUpdated = Instant.now(),
Metadata( accountMetadata = defaultAccountMetadata(),
version = 1, )
lastUpdated = Instant.now(), writeToLocalStorage(new, key)
accountMetadata = emptyMap(),
).also {
this@MetadataDataSourceImpl.metadata = it
}
writeToLocalStorage(new, key)
}
new
} else {
inMemory
} }
new
} }
} }
private suspend fun writeToLocalStorage( private suspend fun writeToLocalStorage(
metadata: Metadata, metadata: Metadata,
key: MetadataKey key: MetadataKey
) = withContext(Dispatchers.IO) { ) {
runCatching { withContext(Dispatchers.IO) {
val file = metadataStorageProvider.getOrCreateStorageFile(key) runCatching {
metadataProvider.writeMetadataToFile(file, metadata, key) val file = metadataStorageProvider.getOrCreateStorageFile(key)
}.onFailure { e -> Twig.warn(e) { "Failed to write address book" } } metadataProvider.writeMetadataToFile(file, metadata, key)
}.onFailure { e -> Twig.warn(e) { "Failed to write address book" } }
}
} }
private suspend fun updateMetadataAnnotation( private suspend fun updateMetadataAnnotation(
txId: String, txId: String,
account: AccountUuid,
key: MetadataKey, key: MetadataKey,
transform: (AnnotationMetadata) -> AnnotationMetadata transform: (AnnotationMetadata) -> AnnotationMetadata
): Metadata { ): Metadata {
return updateMetadata( return updateMetadata(
account = account,
key = key, key = key,
transform = { metadata -> transform = { metadata ->
metadata.copy( metadata.copy(
@ -217,12 +188,10 @@ class MetadataDataSourceImpl(
private suspend fun updateMetadataBookmark( private suspend fun updateMetadataBookmark(
txId: String, txId: String,
account: AccountUuid,
key: MetadataKey, key: MetadataKey,
transform: (BookmarkMetadata) -> BookmarkMetadata transform: (BookmarkMetadata) -> BookmarkMetadata
): Metadata { ): Metadata {
return updateMetadata( return updateMetadata(
account = account,
key = key, key = key,
transform = { metadata -> transform = { metadata ->
metadata.copy( metadata.copy(
@ -240,30 +209,21 @@ class MetadataDataSourceImpl(
) )
} }
@OptIn(ExperimentalStdlibApi::class)
private suspend fun updateMetadata( private suspend fun updateMetadata(
account: AccountUuid,
key: MetadataKey, key: MetadataKey,
transform: (AccountMetadata) -> AccountMetadata transform: (AccountMetadata) -> AccountMetadata
): Metadata { ): Metadata {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val metadata = getMetadataInternal(key) val metadata = getMetadataInternal(key)
val accountMetadata = metadata.accountMetadata[account.value.toHexString()] ?: defaultAccountMetadata() val accountMetadata = metadata.accountMetadata
val updatedMetadata = val updatedMetadata =
metadata.copy( metadata.copy(
lastUpdated = Instant.now(), lastUpdated = Instant.now(),
accountMetadata = accountMetadata = transform(accountMetadata)
metadata.accountMetadata
.toMutableMap()
.apply {
put(account.value.toHexString(), transform(accountMetadata))
}
.toMap()
) )
this@MetadataDataSourceImpl.metadata = updatedMetadata
writeToLocalStorage(updatedMetadata, key) writeToLocalStorage(updatedMetadata, key)
updatedMetadata updatedMetadata

View File

@ -13,7 +13,7 @@ data class Metadata(
@Serializable(InstantSerializer::class) @Serializable(InstantSerializer::class)
val lastUpdated: Instant, val lastUpdated: Instant,
@SerialName("accountMetadata") @SerialName("accountMetadata")
val accountMetadata: Map<String, AccountMetadata> val accountMetadata: AccountMetadata
) )
@Serializable @Serializable

View File

@ -2,8 +2,8 @@ package co.electriccoin.zcash.ui.common.provider
import co.electriccoin.zcash.preference.EncryptedPreferenceProvider import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
import co.electriccoin.zcash.preference.api.PreferenceProvider import co.electriccoin.zcash.preference.api.PreferenceProvider
import co.electriccoin.zcash.preference.model.entry.PreferenceDefault
import co.electriccoin.zcash.preference.model.entry.PreferenceKey import co.electriccoin.zcash.preference.model.entry.PreferenceKey
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey
import com.google.crypto.tink.InsecureSecretKeyAccess import com.google.crypto.tink.InsecureSecretKeyAccess
import com.google.crypto.tink.SecretKeyAccess import com.google.crypto.tink.SecretKeyAccess
@ -12,9 +12,12 @@ import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
interface MetadataKeyStorageProvider { interface MetadataKeyStorageProvider {
suspend fun get(): MetadataKey? suspend fun get(account: WalletAccount): MetadataKey?
suspend fun store(key: MetadataKey) suspend fun store(
key: MetadataKey,
account: WalletAccount
)
} }
class MetadataKeyStorageProviderImpl( class MetadataKeyStorageProviderImpl(
@ -22,40 +25,74 @@ class MetadataKeyStorageProviderImpl(
) : MetadataKeyStorageProvider { ) : MetadataKeyStorageProvider {
private val default = MetadataKeyPreferenceDefault() private val default = MetadataKeyPreferenceDefault()
override suspend fun get(): MetadataKey? { override suspend fun get(account: WalletAccount): MetadataKey? {
return default.getValue(encryptedPreferenceProvider()) return default.getValue(
walletAccount = account,
preferenceProvider = encryptedPreferenceProvider(),
)
} }
override suspend fun store(key: MetadataKey) { override suspend fun store(
default.putValue(encryptedPreferenceProvider(), key) key: MetadataKey,
account: WalletAccount
) {
default.putValue(
newValue = key,
walletAccount = account,
preferenceProvider = encryptedPreferenceProvider(),
)
} }
} }
private class MetadataKeyPreferenceDefault : PreferenceDefault<MetadataKey?> { private class MetadataKeyPreferenceDefault {
private val secretKeyAccess: SecretKeyAccess? private val secretKeyAccess: SecretKeyAccess?
get() = InsecureSecretKeyAccess.get() get() = InsecureSecretKeyAccess.get()
override val key: PreferenceKey = PreferenceKey("metadata_key") suspend fun getValue(
walletAccount: WalletAccount,
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)?.decode()
override suspend fun putValue(
preferenceProvider: PreferenceProvider, preferenceProvider: PreferenceProvider,
newValue: MetadataKey? ): MetadataKey? {
) = preferenceProvider.putString(key, newValue?.encode()) return preferenceProvider.getStringSet(
key = getKey(walletAccount)
)?.decode()
}
suspend fun putValue(
newValue: MetadataKey?,
walletAccount: WalletAccount,
preferenceProvider: PreferenceProvider,
) {
preferenceProvider.putStringSet(
key = getKey(walletAccount),
value = newValue?.encode()
)
}
@OptIn(ExperimentalStdlibApi::class)
private fun getKey(walletAccount: WalletAccount): PreferenceKey =
PreferenceKey("metadata_key_${walletAccount.sdkAccount.accountUuid.value.toHexString()}")
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
private fun MetadataKey?.encode() = private fun MetadataKey?.encode(): Set<String>? {
if (this != null) { return if (this != null) {
Base64.encode(this.key.toByteArray(secretKeyAccess)) setOfNotNull(
Base64.encode(this.encryptionBytes.toByteArray(secretKeyAccess)),
this.decryptionBytes?.let { Base64.encode(it.toByteArray(secretKeyAccess)) }
)
} else { } else {
null null
} }
}
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
private fun String?.decode() = private fun Set<String>?.decode() =
if (this != null) { if (this != null) {
MetadataKey(SecretBytes.copyFrom(Base64.decode(this), secretKeyAccess)) MetadataKey(
encryptionBytes = SecretBytes.copyFrom(Base64.decode(this.toList()[0]), secretKeyAccess),
decryptionBytes =
this.toList().getOrNull(1)
?.let { SecretBytes.copyFrom(Base64.decode(it), secretKeyAccess) },
)
} else { } else {
null null
} }

View File

@ -1,26 +1,29 @@
package co.electriccoin.zcash.ui.common.repository package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.model.AccountUuid
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.datasource.MetadataDataSource import co.electriccoin.zcash.ui.common.datasource.MetadataDataSource
import co.electriccoin.zcash.ui.common.model.Metadata import co.electriccoin.zcash.ui.common.model.Metadata
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.provider.MetadataKeyStorageProvider import co.electriccoin.zcash.ui.common.provider.MetadataKeyStorageProvider
import co.electriccoin.zcash.ui.common.provider.PersistableWalletProvider import co.electriccoin.zcash.ui.common.provider.PersistableWalletProvider
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey
import co.electriccoin.zcash.ui.util.CloseableScopeHolder
import co.electriccoin.zcash.ui.util.CloseableScopeHolderImpl
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
interface MetadataRepository { interface MetadataRepository {
val metadata: Flow<Metadata?> val metadata: Flow<Metadata?>
@ -36,125 +39,158 @@ interface MetadataRepository {
suspend fun markTxMemoAsRead(txId: String) suspend fun markTxMemoAsRead(txId: String)
suspend fun resetMetadata()
fun observeTransactionMetadataByTxId(txId: String): Flow<TransactionMetadata?> fun observeTransactionMetadataByTxId(txId: String): Flow<TransactionMetadata?>
} }
class MetadataRepositoryImpl( class MetadataRepositoryImpl(
private val accountDataSource: AccountDataSource,
private val metadataDataSource: MetadataDataSource, private val metadataDataSource: MetadataDataSource,
private val metadataKeyStorageProvider: MetadataKeyStorageProvider, private val metadataKeyStorageProvider: MetadataKeyStorageProvider,
private val accountDataSource: AccountDataSource,
private val persistableWalletProvider: PersistableWalletProvider, private val persistableWalletProvider: PersistableWalletProvider,
) : MetadataRepository { ) : MetadataRepository, CloseableScopeHolder by CloseableScopeHolderImpl(Dispatchers.IO) {
private val semaphore = Mutex() private val command = Channel<Command>()
private val cache = MutableStateFlow<Metadata?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
override val metadata: Flow<Metadata?> = override val metadata: Flow<Metadata?> =
cache accountDataSource
.onSubscription { .selectedAccount
withNonCancellableSemaphore { .flatMapLatest { account ->
ensureSynchronization() channelFlow {
send(null)
if (account != null) {
val metadataKey = getMetadataKey(account)
send(metadataDataSource.getMetadata(metadataKey))
launch {
command
.receiveAsFlow()
.filter {
it.account.sdkAccount.accountUuid == account.sdkAccount.accountUuid
}
.collect { command ->
val new =
when (command) {
is Command.CreateOrUpdateTxNote ->
metadataDataSource.createOrUpdateTxNote(
txId = command.txId,
key = metadataKey,
note = command.note
)
is Command.DeleteTxNote ->
metadataDataSource.deleteTxNote(
txId = command.txId,
key = metadataKey,
)
is Command.FlipTxBookmark ->
metadataDataSource.flipTxAsBookmarked(
txId = command.txId,
key = metadataKey,
)
is Command.MarkTxMemoAsRead ->
metadataDataSource.markTxMemoAsRead(
txId = command.txId,
key = metadataKey,
)
}
send(new)
}
}
}
awaitClose {
// do nothing
}
} }
} }
.stateIn(
scope = scope,
started = SharingStarted.Lazily,
initialValue = null
)
override suspend fun flipTxBookmark(txId: String) = override suspend fun flipTxBookmark(txId: String) {
mutateMetadata { scope.launch {
metadataDataSource.flipTxAsBookmarked( command.send(
txId = txId, Command.FlipTxBookmark(
key = getMetadataKey(), txId = txId,
account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid account = accountDataSource.getSelectedAccount()
)
) )
} }
}
override suspend fun createOrUpdateTxNote( override suspend fun createOrUpdateTxNote(
txId: String, txId: String,
note: String note: String
) = mutateMetadata { ) {
metadataDataSource.createOrUpdateTxNote( scope.launch {
txId = txId, command.send(
note = note, Command.CreateOrUpdateTxNote(
key = getMetadataKey(), txId = txId,
account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid note = note,
) account = accountDataSource.getSelectedAccount()
} )
)
override suspend fun deleteTxNote(txId: String) = }
mutateMetadata { }
metadataDataSource.deleteTxNote(
txId = txId, override suspend fun deleteTxNote(txId: String) {
key = getMetadataKey(), scope.launch {
account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid command.send(
Command.DeleteTxNote(
txId = txId,
account = accountDataSource.getSelectedAccount()
)
)
}
}
override suspend fun markTxMemoAsRead(txId: String) {
scope.launch {
command.send(
Command.MarkTxMemoAsRead(
txId = txId,
account = accountDataSource.getSelectedAccount()
)
) )
} }
override suspend fun markTxMemoAsRead(txId: String) =
mutateMetadata {
metadataDataSource.markTxMemoAsRead(
txId = txId,
key = getMetadataKey(),
account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid
)
}
override suspend fun resetMetadata() {
withNonCancellableSemaphore {
metadataDataSource.resetMetadata()
cache.update { null }
}
} }
@OptIn(ExperimentalStdlibApi::class)
override fun observeTransactionMetadataByTxId(txId: String): Flow<TransactionMetadata?> = override fun observeTransactionMetadataByTxId(txId: String): Flow<TransactionMetadata?> =
combine<Metadata?, AccountUuid, TransactionMetadata?>( metadata
metadata, .map<Metadata?, TransactionMetadata?> { metadata ->
accountDataSource.selectedAccount.filterNotNull().map { it.sdkAccount.accountUuid }.distinctUntilChanged() val accountMetadata = metadata?.accountMetadata
) { metadata, account ->
val accountMetadata = metadata?.accountMetadata?.get(account.value.toHexString())
TransactionMetadata( TransactionMetadata(
isBookmarked = accountMetadata?.bookmarked?.find { it.txId == txId }?.isBookmarked == true, isBookmarked = accountMetadata?.bookmarked?.find { it.txId == txId }?.isBookmarked == true,
isRead = accountMetadata?.read?.any { it == txId } == true, isRead = accountMetadata?.read?.any { it == txId } == true,
note = accountMetadata?.annotations?.find { it.txId == txId }?.content, note = accountMetadata?.annotations?.find { it.txId == txId }?.content,
) )
}.distinctUntilChanged().onStart { emit(null) } }
.distinctUntilChanged()
.onStart { emit(null) }
private suspend fun ensureSynchronization() { private suspend fun getMetadataKey(selectedAccount: WalletAccount): MetadataKey {
if (cache.value == null) { val key = metadataKeyStorageProvider.get(selectedAccount)
val metadata = metadataDataSource.getMetadata(key = getMetadataKey())
metadataDataSource.save(metadata = metadata, key = getMetadataKey())
cache.update { metadata }
}
}
private suspend fun mutateMetadata(block: suspend () -> Metadata) =
withNonCancellableSemaphore {
ensureSynchronization()
val new = block()
cache.update { new }
}
private suspend fun withNonCancellableSemaphore(block: suspend () -> Unit) =
withContext(NonCancellable + Dispatchers.Default) {
semaphore.withLock { block() }
}
private suspend fun getMetadataKey(): MetadataKey {
val key = metadataKeyStorageProvider.get()
return if (key != null) { return if (key != null) {
key key
} else { } else {
val account = accountDataSource.getZashiAccount()
val persistableWallet = persistableWalletProvider.getPersistableWallet() val persistableWallet = persistableWalletProvider.getPersistableWallet()
val zashiAccount = accountDataSource.getZashiAccount()
val newKey = val newKey =
MetadataKey.derive( MetadataKey.derive(
seedPhrase = persistableWallet.seedPhrase, seedPhrase = persistableWallet.seedPhrase,
network = persistableWallet.network, network = persistableWallet.network,
account = account zashiAccount = zashiAccount,
selectedAccount = selectedAccount
) )
metadataKeyStorageProvider.store(newKey) metadataKeyStorageProvider.store(newKey, selectedAccount)
newKey newKey
} }
} }
@ -165,3 +201,28 @@ data class TransactionMetadata(
val isRead: Boolean, val isRead: Boolean,
val note: String? val note: String?
) )
private sealed interface Command {
val account: WalletAccount
data class FlipTxBookmark(
val txId: String,
override val account: WalletAccount
) : Command
data class CreateOrUpdateTxNote(
val txId: String,
val note: String,
override val account: WalletAccount
) : Command
data class DeleteTxNote(
val txId: String,
override val account: WalletAccount
) : Command
data class MarkTxMemoAsRead(
val txId: String,
override val account: WalletAccount
) : Command
}

View File

@ -1,82 +0,0 @@
package co.electriccoin.zcash.ui.common.serialization
import com.google.crypto.tink.subtle.ChaCha20Poly1305
import com.google.crypto.tink.subtle.Random
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
interface Encryptor<KEY : Key, T> {
fun encrypt(
key: KEY,
outputStream: OutputStream,
data: T
)
fun decrypt(
key: KEY,
inputStream: InputStream
): T
}
abstract class BaseEncryptor<KEY : Key, T> : BaseSerializer(), Encryptor<KEY, T> {
abstract val version: Int
abstract val saltSize: Int
protected abstract fun serialize(
outputStream: ByteArrayOutputStream,
data: T
)
protected abstract fun deserialize(inputStream: ByteArrayInputStream): T
override fun encrypt(
key: KEY,
outputStream: OutputStream,
data: T
) {
// Generate a fresh one-time key for this ciphertext.
val salt = Random.randBytes(saltSize)
val cipherText =
ByteArrayOutputStream()
.use { stream ->
serialize(stream, data)
stream.toByteArray()
}.let {
val derivedKey = key.deriveEncryptionKey(salt)
// Tink encodes the ciphertext as `nonce || ciphertext || tag`.
val cipher = ChaCha20Poly1305.create(derivedKey)
cipher.encrypt(it, null)
}
outputStream.write(version.createByteArray())
outputStream.write(salt)
outputStream.write(cipherText)
}
override fun decrypt(
key: KEY,
inputStream: InputStream
): T {
val version = inputStream.readInt()
if (version != this.version) {
throw UnknownEncryptionVersionException()
}
val salt = ByteArray(saltSize)
require(inputStream.read(salt) == salt.size) { "Input is too short" }
val ciphertext = inputStream.readBytes()
val derivedKey = key.deriveEncryptionKey(salt)
val cipher = ChaCha20Poly1305.create(derivedKey)
val plaintext = cipher.decrypt(ciphertext, null)
return plaintext.inputStream().use { stream ->
deserialize(stream)
}
}
}
class UnknownEncryptionVersionException : RuntimeException("Unknown encryption version")

View File

@ -0,0 +1,3 @@
package co.electriccoin.zcash.ui.common.serialization
class UnknownEncryptionVersionException : RuntimeException("Unknown encryption version")

View File

@ -3,27 +3,89 @@ package co.electriccoin.zcash.ui.common.serialization.addressbook
import co.electriccoin.zcash.ui.common.model.AddressBook import co.electriccoin.zcash.ui.common.model.AddressBook
import co.electriccoin.zcash.ui.common.serialization.ADDRESS_BOOK_ENCRYPTION_V1 import co.electriccoin.zcash.ui.common.serialization.ADDRESS_BOOK_ENCRYPTION_V1
import co.electriccoin.zcash.ui.common.serialization.ADDRESS_BOOK_SALT_SIZE import co.electriccoin.zcash.ui.common.serialization.ADDRESS_BOOK_SALT_SIZE
import co.electriccoin.zcash.ui.common.serialization.BaseEncryptor import co.electriccoin.zcash.ui.common.serialization.BaseSerializer
import co.electriccoin.zcash.ui.common.serialization.Encryptor import co.electriccoin.zcash.ui.common.serialization.UnknownEncryptionVersionException
import com.google.crypto.tink.subtle.ChaCha20Poly1305
import com.google.crypto.tink.subtle.Random
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
interface AddressBookEncryptor : Encryptor<AddressBookKey, AddressBook> interface AddressBookEncryptor {
fun encrypt(
key: AddressBookKey,
outputStream: OutputStream,
data: AddressBook
)
fun decrypt(
key: AddressBookKey,
inputStream: InputStream
): AddressBook
}
class AddressBookEncryptorImpl( class AddressBookEncryptorImpl(
private val addressBookSerializer: AddressBookSerializer, private val addressBookSerializer: AddressBookSerializer,
) : AddressBookEncryptor, BaseEncryptor<AddressBookKey, AddressBook>() { ) : AddressBookEncryptor, BaseSerializer() {
override val version: Int = ADDRESS_BOOK_ENCRYPTION_V1 private val version: Int = ADDRESS_BOOK_ENCRYPTION_V1
override val saltSize: Int = ADDRESS_BOOK_SALT_SIZE private val saltSize: Int = ADDRESS_BOOK_SALT_SIZE
override fun serialize( override fun encrypt(
key: AddressBookKey,
outputStream: OutputStream,
data: AddressBook
) {
// Generate a fresh one-time key for this ciphertext.
val salt = Random.randBytes(saltSize)
val cipherText =
ByteArrayOutputStream()
.use { stream ->
serialize(stream, data)
stream.toByteArray()
}.let {
val derivedKey = key.deriveEncryptionKey(salt)
// Tink encodes the ciphertext as `nonce || ciphertext || tag`.
val cipher = ChaCha20Poly1305.create(derivedKey)
cipher.encrypt(it, null)
}
outputStream.write(version.createByteArray())
outputStream.write(salt)
outputStream.write(cipherText)
}
override fun decrypt(
key: AddressBookKey,
inputStream: InputStream
): AddressBook {
val version = inputStream.readInt()
if (version != this.version) {
throw UnknownEncryptionVersionException()
}
val salt = ByteArray(saltSize)
require(inputStream.read(salt) == salt.size) { "Input is too short" }
val ciphertext = inputStream.readBytes()
val derivedKey = key.deriveEncryptionKey(salt)
val cipher = ChaCha20Poly1305.create(derivedKey)
val plaintext = cipher.decrypt(ciphertext, null)
return plaintext.inputStream().use { stream ->
deserialize(stream)
}
}
private fun serialize(
outputStream: ByteArrayOutputStream, outputStream: ByteArrayOutputStream,
data: AddressBook data: AddressBook
) { ) {
addressBookSerializer.serializeAddressBook(outputStream, data) addressBookSerializer.serializeAddressBook(outputStream, data)
} }
override fun deserialize(inputStream: ByteArrayInputStream): AddressBook { private fun deserialize(inputStream: ByteArrayInputStream): AddressBook {
return addressBookSerializer.deserializeAddressBook(inputStream) return addressBookSerializer.deserializeAddressBook(inputStream)
} }
} }

View File

@ -1,36 +1,38 @@
package co.electriccoin.zcash.ui.common.serialization.metada package co.electriccoin.zcash.ui.common.serialization.metada
import co.electriccoin.zcash.ui.common.model.Metadata import co.electriccoin.zcash.ui.common.model.Metadata
import co.electriccoin.zcash.ui.common.serialization.BaseEncryptor import co.electriccoin.zcash.ui.common.serialization.BaseSerializer
import co.electriccoin.zcash.ui.common.serialization.Encryptor
import co.electriccoin.zcash.ui.common.serialization.METADATA_ENCRYPTION_V1 import co.electriccoin.zcash.ui.common.serialization.METADATA_ENCRYPTION_V1
import co.electriccoin.zcash.ui.common.serialization.METADATA_SALT_SIZE import co.electriccoin.zcash.ui.common.serialization.METADATA_SALT_SIZE
import co.electriccoin.zcash.ui.common.serialization.UnknownEncryptionVersionException import co.electriccoin.zcash.ui.common.serialization.UnknownEncryptionVersionException
import com.google.crypto.tink.aead.ChaCha20Poly1305Key
import com.google.crypto.tink.subtle.ChaCha20Poly1305 import com.google.crypto.tink.subtle.ChaCha20Poly1305
import com.google.crypto.tink.subtle.Random import com.google.crypto.tink.subtle.Random
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import kotlin.jvm.Throws
interface MetadataEncryptor : Encryptor<MetadataKey, Metadata> interface MetadataEncryptor {
fun encrypt(
key: MetadataKey,
outputStream: OutputStream,
data: Metadata
)
@Throws(DecryptionException::class)
fun decrypt(
key: MetadataKey,
inputStream: InputStream
): Metadata
}
class MetadataEncryptorImpl( class MetadataEncryptorImpl(
private val metadataSerializer: MetadataSerializer, private val metadataSerializer: MetadataSerializer,
) : MetadataEncryptor, BaseEncryptor<MetadataKey, Metadata>() { ) : MetadataEncryptor, BaseSerializer() {
override val version: Int = METADATA_ENCRYPTION_V1 private val version: Int = METADATA_ENCRYPTION_V1
override val saltSize: Int = METADATA_SALT_SIZE private val saltSize: Int = METADATA_SALT_SIZE
override fun serialize(
outputStream: ByteArrayOutputStream,
data: Metadata
) {
metadataSerializer.serialize(outputStream, data)
}
override fun deserialize(inputStream: ByteArrayInputStream): Metadata {
return metadataSerializer.deserialize(inputStream)
}
override fun encrypt( override fun encrypt(
key: MetadataKey, key: MetadataKey,
@ -72,12 +74,34 @@ class MetadataEncryptorImpl(
val ciphertext = inputStream.readBytes() val ciphertext = inputStream.readBytes()
val derivedKey = key.deriveEncryptionKey(salt) return decrypt(key.deriveFirstDecryptionKey(salt), ciphertext)
val cipher = ChaCha20Poly1305.create(derivedKey) ?: decrypt(key.deriveSecondDecryptionKey(salt), ciphertext)
val plaintext = cipher.decrypt(ciphertext, null) ?: throw DecryptionException()
}
return plaintext.inputStream().use { stream -> private fun decrypt(
deserialize(stream) key: ChaCha20Poly1305Key?,
} ciphertext: ByteArray
): Metadata? {
if (key == null) return null
return runCatching {
val cipher = ChaCha20Poly1305.create(key)
val plaintext = cipher.decrypt(ciphertext, null)
plaintext.inputStream().use { stream -> deserialize(stream) }
}.getOrNull()
}
private fun serialize(
outputStream: ByteArrayOutputStream,
data: Metadata
) {
metadataSerializer.serialize(outputStream, data)
}
private fun deserialize(inputStream: ByteArrayInputStream): Metadata {
return metadataSerializer.deserialize(inputStream)
} }
} }
class DecryptionException : Exception()

View File

@ -3,8 +3,9 @@ package co.electriccoin.zcash.ui.common.serialization.metada
import cash.z.ecc.android.sdk.model.SeedPhrase import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.android.sdk.model.ZcashNetwork import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.DerivationTool import cash.z.ecc.android.sdk.tool.DerivationTool
import co.electriccoin.zcash.ui.common.model.KeystoneAccount
import co.electriccoin.zcash.ui.common.model.WalletAccount import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.serialization.Key import co.electriccoin.zcash.ui.common.model.ZashiAccount
import co.electriccoin.zcash.ui.common.serialization.METADATA_ENCRYPTION_KEY_SIZE import co.electriccoin.zcash.ui.common.serialization.METADATA_ENCRYPTION_KEY_SIZE
import co.electriccoin.zcash.ui.common.serialization.METADATA_FILE_IDENTIFIER_SIZE import co.electriccoin.zcash.ui.common.serialization.METADATA_FILE_IDENTIFIER_SIZE
import co.electriccoin.zcash.ui.common.serialization.METADATA_SALT_SIZE import co.electriccoin.zcash.ui.common.serialization.METADATA_SALT_SIZE
@ -16,17 +17,20 @@ import com.google.crypto.tink.util.SecretBytes
/** /**
* The long-term key that can decrypt an account's encrypted address book. * The long-term key that can decrypt an account's encrypted address book.
*/ */
class MetadataKey(val key: SecretBytes) : Key { class MetadataKey(
val encryptionBytes: SecretBytes,
val decryptionBytes: SecretBytes?
) {
/** /**
* Derives the filename that this key is able to decrypt. * Derives the filename that this key is able to decrypt.
*/ */
@OptIn(ExperimentalStdlibApi::class) @OptIn(ExperimentalStdlibApi::class)
override fun fileIdentifier(): String { fun fileIdentifier(): String {
val access = InsecureSecretKeyAccess.get() val access = InsecureSecretKeyAccess.get()
val fileIdentifier = val fileIdentifier =
Hkdf.computeHkdf( Hkdf.computeHkdf(
"HMACSHA256", "HMACSHA256",
key.toByteArray(access), encryptionBytes.toByteArray(access),
null, null,
"file_identifier".toByteArray(), "file_identifier".toByteArray(),
METADATA_FILE_IDENTIFIER_SIZE METADATA_FILE_IDENTIFIER_SIZE
@ -34,19 +38,13 @@ class MetadataKey(val key: SecretBytes) : Key {
return "zashi-metadata-" + fileIdentifier.toHexString() return "zashi-metadata-" + fileIdentifier.toHexString()
} }
/** fun deriveEncryptionKey(salt: ByteArray): ChaCha20Poly1305Key {
* Derives a one-time address book encryption key.
*
* At encryption time, the one-time property MUST be ensured by generating a
* random 32-byte salt.
*/
override fun deriveEncryptionKey(salt: ByteArray): ChaCha20Poly1305Key {
assert(salt.size == METADATA_SALT_SIZE) assert(salt.size == METADATA_SALT_SIZE)
val access = InsecureSecretKeyAccess.get() val access = InsecureSecretKeyAccess.get()
val subKey = val subKey =
Hkdf.computeHkdf( Hkdf.computeHkdf(
"HMACSHA256", "HMACSHA256",
key.toByteArray(access), encryptionBytes.toByteArray(access),
null, null,
salt + "encryption_key".toByteArray(), salt + "encryption_key".toByteArray(),
METADATA_ENCRYPTION_KEY_SIZE METADATA_ENCRYPTION_KEY_SIZE
@ -54,28 +52,64 @@ class MetadataKey(val key: SecretBytes) : Key {
return ChaCha20Poly1305Key.create(SecretBytes.copyFrom(subKey, access)) return ChaCha20Poly1305Key.create(SecretBytes.copyFrom(subKey, access))
} }
fun deriveFirstDecryptionKey(salt: ByteArray): ChaCha20Poly1305Key {
return deriveDecryptionkey(salt, encryptionBytes, "encryption_key")
}
fun deriveSecondDecryptionKey(salt: ByteArray): ChaCha20Poly1305Key? {
if (decryptionBytes == null) return null
return deriveDecryptionkey(salt, decryptionBytes, "decryption_key")
}
private fun deriveDecryptionkey(
salt: ByteArray,
decryptionBytes: SecretBytes,
infoKey: String
): ChaCha20Poly1305Key {
assert(salt.size == METADATA_SALT_SIZE)
val access = InsecureSecretKeyAccess.get()
val subKey =
Hkdf.computeHkdf(
"HMACSHA256",
decryptionBytes.toByteArray(access),
null,
salt + infoKey.toByteArray(),
METADATA_ENCRYPTION_KEY_SIZE
)
return ChaCha20Poly1305Key.create(SecretBytes.copyFrom(subKey, access))
}
companion object { companion object {
/**
* Derives the long-term key that can decrypt the given account's encrypted
* address book.
*
* This requires access to the seed phrase. If the app has separate access
* control requirements for the seed phrase and the address book, this key
* should be cached in the app's keystore.
*/
suspend fun derive( suspend fun derive(
seedPhrase: SeedPhrase, seedPhrase: SeedPhrase,
network: ZcashNetwork, network: ZcashNetwork,
account: WalletAccount zashiAccount: ZashiAccount,
selectedAccount: WalletAccount
): MetadataKey { ): MetadataKey {
val key = val key =
DerivationTool.getInstance().deriveArbitraryAccountKey( DerivationTool.getInstance()
contextString = "ZashiMetadataEncryptionV1".toByteArray(), .deriveAccountMetadataKey(
seed = seedPhrase.toByteArray(), seed = seedPhrase.toByteArray(),
network = network, network = network,
accountIndex = account.hdAccountIndex, accountIndex = zashiAccount.hdAccountIndex,
) )
return MetadataKey(SecretBytes.copyFrom(key, InsecureSecretKeyAccess.get())) .derivePrivateUseMetadataKey(
ufvk =
when (selectedAccount) {
is KeystoneAccount -> selectedAccount.sdkAccount.ufvk
is ZashiAccount -> null
},
network = network,
privateUseSubject = "metadata".toByteArray()
)
return MetadataKey(
encryptionBytes = SecretBytes.copyFrom(key[0], InsecureSecretKeyAccess.get()),
decryptionBytes =
key.getOrNull(1)
?.let {
SecretBytes.copyFrom(it, InsecureSecretKeyAccess.get())
}
)
} }
} }
} }

View File

@ -1,14 +1,11 @@
package co.electriccoin.zcash.ui.common.usecase package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
import co.electriccoin.zcash.ui.common.repository.MetadataRepository
class ResetInMemoryDataUseCase( class ResetInMemoryDataUseCase(
private val addressBookRepository: AddressBookRepository, private val addressBookRepository: AddressBookRepository,
private val metadataRepository: MetadataRepository
) { ) {
suspend operator fun invoke() { suspend operator fun invoke() {
addressBookRepository.resetAddressBook() addressBookRepository.resetAddressBook()
metadataRepository.resetMetadata()
} }
} }

View File

@ -32,6 +32,7 @@ import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.util.asScaffoldPaddingValues
import co.electriccoin.zcash.ui.design.util.orDark import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.scaffoldPadding import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.design.util.stringRes
@ -143,7 +144,7 @@ private fun BottomBar(
) { ) {
ZashiBottomBar( ZashiBottomBar(
isElevated = scrollState.value > 0, isElevated = scrollState.value > 0,
modifier = Modifier.scaffoldPadding(paddingValues, top = 0.dp, bottom = 0.dp) contentPadding = paddingValues.asScaffoldPaddingValues(top = 0.dp, bottom = 0.dp)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()