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:
parent
13695bbd97
commit
2c37d086f0
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||||
|
|
|
@ -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?
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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")
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
package co.electriccoin.zcash.ui.common.serialization
|
||||||
|
|
||||||
|
class UnknownEncryptionVersionException : RuntimeException("Unknown encryption version")
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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())
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in New Issue