diff --git a/app/src/main/res/xml/auto_backup_config.xml b/app/src/main/res/xml/auto_backup_config.xml index 58d978da5..9cb0dfb6f 100644 --- a/app/src/main/res/xml/auto_backup_config.xml +++ b/app/src/main/res/xml/auto_backup_config.xml @@ -3,4 +3,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/xml/auto_backup_config_android_12.xml b/app/src/main/res/xml/auto_backup_config_android_12.xml index 481bbaea6..7a8cee609 100644 --- a/app/src/main/res/xml/auto_backup_config_android_12.xml +++ b/app/src/main/res/xml/auto_backup_config_android_12.xml @@ -4,5 +4,8 @@ + \ No newline at end of file diff --git a/preference-api-lib/src/commonMain/kotlin/co/electriccoin/zcash/preference/api/PreferenceProvider.kt b/preference-api-lib/src/commonMain/kotlin/co/electriccoin/zcash/preference/api/PreferenceProvider.kt index c68615ccf..c21b67b51 100644 --- a/preference-api-lib/src/commonMain/kotlin/co/electriccoin/zcash/preference/api/PreferenceProvider.kt +++ b/preference-api-lib/src/commonMain/kotlin/co/electriccoin/zcash/preference/api/PreferenceProvider.kt @@ -11,6 +11,11 @@ interface PreferenceProvider { value: String? ) + suspend fun putStringSet( + key: PreferenceKey, + value: Set? + ) + suspend fun putLong( key: PreferenceKey, value: Long? @@ -20,6 +25,8 @@ interface PreferenceProvider { suspend fun getString(key: PreferenceKey): String? + suspend fun getStringSet(key: PreferenceKey): Set? + fun observe(key: PreferenceKey): Flow suspend fun clearPreferences(): Boolean diff --git a/preference-api-lib/src/commonTest/kotlin/co/electriccoin/zcash/preference/test/MockPreferenceProvider.kt b/preference-api-lib/src/commonTest/kotlin/co/electriccoin/zcash/preference/test/MockPreferenceProvider.kt index a4f065e35..7a2267b47 100644 --- a/preference-api-lib/src/commonTest/kotlin/co/electriccoin/zcash/preference/test/MockPreferenceProvider.kt +++ b/preference-api-lib/src/commonTest/kotlin/co/electriccoin/zcash/preference/test/MockPreferenceProvider.kt @@ -15,6 +15,10 @@ class MockPreferenceProvider( override suspend fun getString(key: PreferenceKey) = map[key.key] + override suspend fun getStringSet(key: PreferenceKey): Set? { + TODO("Not yet implemented") + } + // For the mock implementation, does not support observability of changes override fun observe(key: PreferenceKey): Flow = flow { emit(getString(key)) } @@ -32,6 +36,13 @@ class MockPreferenceProvider( map[key.key] = value } + override suspend fun putStringSet( + key: PreferenceKey, + value: Set? + ) { + TODO("Not yet implemented") + } + override suspend fun putLong( key: PreferenceKey, value: Long? diff --git a/preference-impl-android-lib/src/main/java/co/electriccoin/zcash/preference/AndroidPreferenceProvider.kt b/preference-impl-android-lib/src/main/java/co/electriccoin/zcash/preference/AndroidPreferenceProvider.kt index e273c0b50..370dceefe 100644 --- a/preference-impl-android-lib/src/main/java/co/electriccoin/zcash/preference/AndroidPreferenceProvider.kt +++ b/preference-impl-android-lib/src/main/java/co/electriccoin/zcash/preference/AndroidPreferenceProvider.kt @@ -59,6 +59,22 @@ class AndroidPreferenceProvider private constructor( } } + @SuppressLint("ApplySharedPref") + override suspend fun putStringSet( + key: PreferenceKey, + value: Set? + ) = withContext(dispatcher) { + mutex.withLock { + val editor = sharedPreferences.edit() + + editor.putStringSet(key.key, value) + + editor.commit() + + Unit + } + } + @SuppressLint("ApplySharedPref") override suspend fun putLong( key: PreferenceKey, @@ -91,6 +107,11 @@ class AndroidPreferenceProvider private constructor( sharedPreferences.getString(key.key, null) } + override suspend fun getStringSet(key: PreferenceKey): Set? = + withContext(dispatcher) { + sharedPreferences.getStringSet(key.key, null) + } + @SuppressLint("ApplySharedPref") override suspend fun clearPreferences() = withContext(dispatcher) { diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiBottomBar.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiBottomBar.kt index b4d3c24e2..c850032c1 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiBottomBar.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiBottomBar.kt @@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.design.component import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth @@ -22,6 +23,7 @@ import co.electriccoin.zcash.ui.design.util.stringRes fun ZashiBottomBar( isElevated: Boolean, modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), content: @Composable ColumnScope.() -> Unit, ) { Surface( @@ -29,7 +31,9 @@ fun ZashiBottomBar( color = ZashiColors.Surfaces.bgPrimary, modifier = modifier, ) { - Column { + Column( + modifier = Modifier.padding(contentPadding), + ) { Spacer(modifier = Modifier.height(16.dp)) content() Spacer(modifier = Modifier.height(24.dp)) diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/util/ScaffoldPadding.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/util/ScaffoldPadding.kt index 19dd50a4f..afc0b8ec6 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/util/ScaffoldPadding.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/util/ScaffoldPadding.kt @@ -36,13 +36,17 @@ fun Modifier.scaffoldScrollPadding( ) @Stable -fun PaddingValues.asScaffoldPaddingValues() = - PaddingValues( - top = calculateTopPadding() + ZashiDimensions.Spacing.spacingLg, - bottom = calculateBottomPadding() + ZashiDimensions.Spacing.spacing3xl, - start = ZashiDimensions.Spacing.spacing3xl, - end = ZashiDimensions.Spacing.spacing3xl - ) +fun PaddingValues.asScaffoldPaddingValues( + top: Dp = calculateTopPadding() + ZashiDimensions.Spacing.spacingLg, + bottom: Dp = calculateBottomPadding() + ZashiDimensions.Spacing.spacing3xl, + start: Dp = ZashiDimensions.Spacing.spacing3xl, + end: Dp = ZashiDimensions.Spacing.spacing3xl +) = PaddingValues( + top = top, + bottom = bottom, + start = start, + end = end, +) @Stable fun PaddingValues.asScaffoldScrollPaddingValues( diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/MetadataDataSource.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/MetadataDataSource.kt index 2f511f9e8..b1766333a 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/MetadataDataSource.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/MetadataDataSource.kt @@ -1,6 +1,5 @@ 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.ui.common.model.AccountMetadata 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.provider.MetadataProvider 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex @@ -20,26 +20,22 @@ interface MetadataDataSource { suspend fun flipTxAsBookmarked( txId: String, - account: AccountUuid, key: MetadataKey ): Metadata suspend fun createOrUpdateTxNote( txId: String, note: String, - account: AccountUuid, key: MetadataKey ): Metadata suspend fun deleteTxNote( txId: String, - account: AccountUuid, key: MetadataKey ): Metadata suspend fun markTxMemoAsRead( txId: String, - account: AccountUuid, key: MetadataKey ): Metadata @@ -47,8 +43,6 @@ interface MetadataDataSource { metadata: Metadata, key: MetadataKey ) - - suspend fun resetMetadata() } @Suppress("TooManyFunctions") @@ -56,8 +50,6 @@ class MetadataDataSourceImpl( private val metadataStorageProvider: MetadataStorageProvider, private val metadataProvider: MetadataProvider, ) : MetadataDataSource { - private var metadata: Metadata? = null - private val mutex = Mutex() override suspend fun getMetadata(key: MetadataKey): Metadata = @@ -67,11 +59,10 @@ class MetadataDataSourceImpl( override suspend fun flipTxAsBookmarked( txId: String, - account: AccountUuid, key: MetadataKey, ): Metadata = mutex.withLock { - updateMetadataBookmark(txId = txId, account = account, key = key) { + updateMetadataBookmark(txId = txId, key = key) { it.copy( isBookmarked = !it.isBookmarked, lastUpdated = Instant.now(), @@ -82,13 +73,11 @@ class MetadataDataSourceImpl( override suspend fun createOrUpdateTxNote( txId: String, note: String, - account: AccountUuid, key: MetadataKey ): Metadata = mutex.withLock { updateMetadataAnnotation( txId = txId, - account = account, key = key ) { it.copy( @@ -100,13 +89,11 @@ class MetadataDataSourceImpl( override suspend fun deleteTxNote( txId: String, - account: AccountUuid, key: MetadataKey ): Metadata = mutex.withLock { updateMetadataAnnotation( txId = txId, - account = account, key = key ) { it.copy( @@ -118,12 +105,10 @@ class MetadataDataSourceImpl( override suspend fun markTxMemoAsRead( txId: String, - account: AccountUuid, key: MetadataKey ): Metadata = mutex.withLock { updateMetadata( - account = account, key = key, transform = { metadata -> metadata.copy( @@ -138,14 +123,8 @@ class MetadataDataSourceImpl( key: MetadataKey ) = mutex.withLock { writeToLocalStorage(metadata, key) - this.metadata = metadata } - override suspend fun resetMetadata() = - mutex.withLock { - metadata = null - } - private suspend fun getMetadataInternal(key: MetadataKey): Metadata { fun readLocalFileToMetadata(key: MetadataKey): Metadata? { val encryptedFile = @@ -158,46 +137,38 @@ class MetadataDataSourceImpl( } return withContext(Dispatchers.IO) { - val inMemory = metadata - - if (inMemory == null) { - var new: Metadata? = readLocalFileToMetadata(key) - if (new == null) { - new = - Metadata( - version = 1, - lastUpdated = Instant.now(), - accountMetadata = emptyMap(), - ).also { - this@MetadataDataSourceImpl.metadata = it - } - writeToLocalStorage(new, key) - } - new - } else { - inMemory + var new: Metadata? = readLocalFileToMetadata(key) + if (new == null) { + new = + Metadata( + version = METADATA_SERIALIZATION_V1, + lastUpdated = Instant.now(), + accountMetadata = defaultAccountMetadata(), + ) + writeToLocalStorage(new, key) } + new } } private suspend fun writeToLocalStorage( metadata: Metadata, key: MetadataKey - ) = withContext(Dispatchers.IO) { - runCatching { - val file = metadataStorageProvider.getOrCreateStorageFile(key) - metadataProvider.writeMetadataToFile(file, metadata, key) - }.onFailure { e -> Twig.warn(e) { "Failed to write address book" } } + ) { + withContext(Dispatchers.IO) { + runCatching { + val file = metadataStorageProvider.getOrCreateStorageFile(key) + metadataProvider.writeMetadataToFile(file, metadata, key) + }.onFailure { e -> Twig.warn(e) { "Failed to write address book" } } + } } private suspend fun updateMetadataAnnotation( txId: String, - account: AccountUuid, key: MetadataKey, transform: (AnnotationMetadata) -> AnnotationMetadata ): Metadata { return updateMetadata( - account = account, key = key, transform = { metadata -> metadata.copy( @@ -217,12 +188,10 @@ class MetadataDataSourceImpl( private suspend fun updateMetadataBookmark( txId: String, - account: AccountUuid, key: MetadataKey, transform: (BookmarkMetadata) -> BookmarkMetadata ): Metadata { return updateMetadata( - account = account, key = key, transform = { metadata -> metadata.copy( @@ -240,30 +209,21 @@ class MetadataDataSourceImpl( ) } - @OptIn(ExperimentalStdlibApi::class) private suspend fun updateMetadata( - account: AccountUuid, key: MetadataKey, transform: (AccountMetadata) -> AccountMetadata ): Metadata { return withContext(Dispatchers.IO) { val metadata = getMetadataInternal(key) - val accountMetadata = metadata.accountMetadata[account.value.toHexString()] ?: defaultAccountMetadata() + val accountMetadata = metadata.accountMetadata val updatedMetadata = metadata.copy( lastUpdated = Instant.now(), - accountMetadata = - metadata.accountMetadata - .toMutableMap() - .apply { - put(account.value.toHexString(), transform(accountMetadata)) - } - .toMap() + accountMetadata = transform(accountMetadata) ) - this@MetadataDataSourceImpl.metadata = updatedMetadata writeToLocalStorage(updatedMetadata, key) updatedMetadata diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/Metadata.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/Metadata.kt index 74a04479b..e8ccc0b40 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/Metadata.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/Metadata.kt @@ -13,7 +13,7 @@ data class Metadata( @Serializable(InstantSerializer::class) val lastUpdated: Instant, @SerialName("accountMetadata") - val accountMetadata: Map + val accountMetadata: AccountMetadata ) @Serializable diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/MetadataKeyStorageProvider.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/MetadataKeyStorageProvider.kt index 8de897e09..54ddb1310 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/MetadataKeyStorageProvider.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/MetadataKeyStorageProvider.kt @@ -2,8 +2,8 @@ package co.electriccoin.zcash.ui.common.provider import co.electriccoin.zcash.preference.EncryptedPreferenceProvider 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.ui.common.model.WalletAccount import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey import com.google.crypto.tink.InsecureSecretKeyAccess import com.google.crypto.tink.SecretKeyAccess @@ -12,9 +12,12 @@ import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi 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( @@ -22,40 +25,74 @@ class MetadataKeyStorageProviderImpl( ) : MetadataKeyStorageProvider { private val default = MetadataKeyPreferenceDefault() - override suspend fun get(): MetadataKey? { - return default.getValue(encryptedPreferenceProvider()) + override suspend fun get(account: WalletAccount): MetadataKey? { + return default.getValue( + walletAccount = account, + preferenceProvider = encryptedPreferenceProvider(), + ) } - override suspend fun store(key: MetadataKey) { - default.putValue(encryptedPreferenceProvider(), key) + override suspend fun store( + key: MetadataKey, + account: WalletAccount + ) { + default.putValue( + newValue = key, + walletAccount = account, + preferenceProvider = encryptedPreferenceProvider(), + ) } } -private class MetadataKeyPreferenceDefault : PreferenceDefault { +private class MetadataKeyPreferenceDefault { private val secretKeyAccess: SecretKeyAccess? get() = InsecureSecretKeyAccess.get() - override val key: PreferenceKey = PreferenceKey("metadata_key") - - override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)?.decode() - - override suspend fun putValue( + suspend fun getValue( + walletAccount: WalletAccount, preferenceProvider: PreferenceProvider, - newValue: MetadataKey? - ) = preferenceProvider.putString(key, newValue?.encode()) + ): MetadataKey? { + 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) - private fun MetadataKey?.encode() = - if (this != null) { - Base64.encode(this.key.toByteArray(secretKeyAccess)) + private fun MetadataKey?.encode(): Set? { + return if (this != null) { + setOfNotNull( + Base64.encode(this.encryptionBytes.toByteArray(secretKeyAccess)), + this.decryptionBytes?.let { Base64.encode(it.toByteArray(secretKeyAccess)) } + ) } else { null } + } @OptIn(ExperimentalEncodingApi::class) - private fun String?.decode() = + private fun Set?.decode() = 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 { null } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/MetadataRepository.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/MetadataRepository.kt index 52a4fb6df..9ad8fdd9a 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/MetadataRepository.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/MetadataRepository.kt @@ -1,26 +1,29 @@ 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.MetadataDataSource 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.PersistableWalletProvider 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.NonCancellable +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.channelFlow 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.onStart -import kotlinx.coroutines.flow.onSubscription -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch interface MetadataRepository { val metadata: Flow @@ -36,125 +39,158 @@ interface MetadataRepository { suspend fun markTxMemoAsRead(txId: String) - suspend fun resetMetadata() - fun observeTransactionMetadataByTxId(txId: String): Flow } class MetadataRepositoryImpl( + private val accountDataSource: AccountDataSource, private val metadataDataSource: MetadataDataSource, private val metadataKeyStorageProvider: MetadataKeyStorageProvider, - private val accountDataSource: AccountDataSource, private val persistableWalletProvider: PersistableWalletProvider, -) : MetadataRepository { - private val semaphore = Mutex() - - private val cache = MutableStateFlow(null) +) : MetadataRepository, CloseableScopeHolder by CloseableScopeHolderImpl(Dispatchers.IO) { + private val command = Channel() + @OptIn(ExperimentalCoroutinesApi::class) override val metadata: Flow = - cache - .onSubscription { - withNonCancellableSemaphore { - ensureSynchronization() + accountDataSource + .selectedAccount + .flatMapLatest { account -> + 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) = - mutateMetadata { - metadataDataSource.flipTxAsBookmarked( - txId = txId, - key = getMetadataKey(), - account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid + override suspend fun flipTxBookmark(txId: String) { + scope.launch { + command.send( + Command.FlipTxBookmark( + txId = txId, + account = accountDataSource.getSelectedAccount() + ) ) } + } override suspend fun createOrUpdateTxNote( txId: String, note: String - ) = mutateMetadata { - metadataDataSource.createOrUpdateTxNote( - txId = txId, - note = note, - key = getMetadataKey(), - account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid - ) - } - - override suspend fun deleteTxNote(txId: String) = - mutateMetadata { - metadataDataSource.deleteTxNote( - txId = txId, - key = getMetadataKey(), - account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid + ) { + scope.launch { + command.send( + Command.CreateOrUpdateTxNote( + txId = txId, + note = note, + account = accountDataSource.getSelectedAccount() + ) + ) + } + } + + override suspend fun deleteTxNote(txId: String) { + scope.launch { + 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 = - combine( - metadata, - accountDataSource.selectedAccount.filterNotNull().map { it.sdkAccount.accountUuid }.distinctUntilChanged() - ) { metadata, account -> - val accountMetadata = metadata?.accountMetadata?.get(account.value.toHexString()) + metadata + .map { metadata -> + val accountMetadata = metadata?.accountMetadata - TransactionMetadata( - isBookmarked = accountMetadata?.bookmarked?.find { it.txId == txId }?.isBookmarked == true, - isRead = accountMetadata?.read?.any { it == txId } == true, - note = accountMetadata?.annotations?.find { it.txId == txId }?.content, - ) - }.distinctUntilChanged().onStart { emit(null) } + TransactionMetadata( + isBookmarked = accountMetadata?.bookmarked?.find { it.txId == txId }?.isBookmarked == true, + isRead = accountMetadata?.read?.any { it == txId } == true, + note = accountMetadata?.annotations?.find { it.txId == txId }?.content, + ) + } + .distinctUntilChanged() + .onStart { emit(null) } - private suspend fun ensureSynchronization() { - if (cache.value == null) { - 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() + private suspend fun getMetadataKey(selectedAccount: WalletAccount): MetadataKey { + val key = metadataKeyStorageProvider.get(selectedAccount) return if (key != null) { key } else { - val account = accountDataSource.getZashiAccount() val persistableWallet = persistableWalletProvider.getPersistableWallet() + val zashiAccount = accountDataSource.getZashiAccount() val newKey = MetadataKey.derive( seedPhrase = persistableWallet.seedPhrase, network = persistableWallet.network, - account = account + zashiAccount = zashiAccount, + selectedAccount = selectedAccount ) - metadataKeyStorageProvider.store(newKey) + metadataKeyStorageProvider.store(newKey, selectedAccount) newKey } } @@ -165,3 +201,28 @@ data class TransactionMetadata( val isRead: Boolean, 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 +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/Encryptor.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/Encryptor.kt deleted file mode 100644 index fa1a9cac5..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/Encryptor.kt +++ /dev/null @@ -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 { - fun encrypt( - key: KEY, - outputStream: OutputStream, - data: T - ) - - fun decrypt( - key: KEY, - inputStream: InputStream - ): T -} - -abstract class BaseEncryptor : BaseSerializer(), Encryptor { - 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") diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/UnknownEncryptionVersionException.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/UnknownEncryptionVersionException.kt new file mode 100644 index 000000000..e5df37a6b --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/UnknownEncryptionVersionException.kt @@ -0,0 +1,3 @@ +package co.electriccoin.zcash.ui.common.serialization + +class UnknownEncryptionVersionException : RuntimeException("Unknown encryption version") diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookEncryptor.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookEncryptor.kt index 2390be606..f244539fe 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookEncryptor.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookEncryptor.kt @@ -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.serialization.ADDRESS_BOOK_ENCRYPTION_V1 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.Encryptor +import co.electriccoin.zcash.ui.common.serialization.BaseSerializer +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.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream -interface AddressBookEncryptor : Encryptor +interface AddressBookEncryptor { + fun encrypt( + key: AddressBookKey, + outputStream: OutputStream, + data: AddressBook + ) + + fun decrypt( + key: AddressBookKey, + inputStream: InputStream + ): AddressBook +} class AddressBookEncryptorImpl( private val addressBookSerializer: AddressBookSerializer, -) : AddressBookEncryptor, BaseEncryptor() { - override val version: Int = ADDRESS_BOOK_ENCRYPTION_V1 - override val saltSize: Int = ADDRESS_BOOK_SALT_SIZE +) : AddressBookEncryptor, BaseSerializer() { + private val version: Int = ADDRESS_BOOK_ENCRYPTION_V1 + 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, data: AddressBook ) { addressBookSerializer.serializeAddressBook(outputStream, data) } - override fun deserialize(inputStream: ByteArrayInputStream): AddressBook { + private fun deserialize(inputStream: ByteArrayInputStream): AddressBook { return addressBookSerializer.deserializeAddressBook(inputStream) } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/metada/MetadaEncryptor.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/metada/MetadaEncryptor.kt index 950b3aca0..6ca8650f8 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/metada/MetadaEncryptor.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/metada/MetadaEncryptor.kt @@ -1,36 +1,38 @@ package co.electriccoin.zcash.ui.common.serialization.metada import co.electriccoin.zcash.ui.common.model.Metadata -import co.electriccoin.zcash.ui.common.serialization.BaseEncryptor -import co.electriccoin.zcash.ui.common.serialization.Encryptor +import co.electriccoin.zcash.ui.common.serialization.BaseSerializer 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.UnknownEncryptionVersionException +import com.google.crypto.tink.aead.ChaCha20Poly1305Key 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 +import kotlin.jvm.Throws -interface MetadataEncryptor : Encryptor +interface MetadataEncryptor { + fun encrypt( + key: MetadataKey, + outputStream: OutputStream, + data: Metadata + ) + + @Throws(DecryptionException::class) + fun decrypt( + key: MetadataKey, + inputStream: InputStream + ): Metadata +} class MetadataEncryptorImpl( private val metadataSerializer: MetadataSerializer, -) : MetadataEncryptor, BaseEncryptor() { - override val version: Int = METADATA_ENCRYPTION_V1 - override 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) - } +) : MetadataEncryptor, BaseSerializer() { + private val version: Int = METADATA_ENCRYPTION_V1 + private val saltSize: Int = METADATA_SALT_SIZE override fun encrypt( key: MetadataKey, @@ -72,12 +74,34 @@ class MetadataEncryptorImpl( val ciphertext = inputStream.readBytes() - val derivedKey = key.deriveEncryptionKey(salt) - val cipher = ChaCha20Poly1305.create(derivedKey) - val plaintext = cipher.decrypt(ciphertext, null) + return decrypt(key.deriveFirstDecryptionKey(salt), ciphertext) + ?: decrypt(key.deriveSecondDecryptionKey(salt), ciphertext) + ?: throw DecryptionException() + } - return plaintext.inputStream().use { stream -> - deserialize(stream) - } + private fun decrypt( + 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() diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/metada/MetadataKey.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/metada/MetadataKey.kt index 222aea672..873401b06 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/metada/MetadataKey.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/metada/MetadataKey.kt @@ -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.ZcashNetwork 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.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_FILE_IDENTIFIER_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. */ -class MetadataKey(val key: SecretBytes) : Key { +class MetadataKey( + val encryptionBytes: SecretBytes, + val decryptionBytes: SecretBytes? +) { /** * Derives the filename that this key is able to decrypt. */ @OptIn(ExperimentalStdlibApi::class) - override fun fileIdentifier(): String { + fun fileIdentifier(): String { val access = InsecureSecretKeyAccess.get() val fileIdentifier = Hkdf.computeHkdf( "HMACSHA256", - key.toByteArray(access), + encryptionBytes.toByteArray(access), null, "file_identifier".toByteArray(), METADATA_FILE_IDENTIFIER_SIZE @@ -34,19 +38,13 @@ class MetadataKey(val key: SecretBytes) : Key { return "zashi-metadata-" + fileIdentifier.toHexString() } - /** - * 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 { + fun deriveEncryptionKey(salt: ByteArray): ChaCha20Poly1305Key { assert(salt.size == METADATA_SALT_SIZE) val access = InsecureSecretKeyAccess.get() val subKey = Hkdf.computeHkdf( "HMACSHA256", - key.toByteArray(access), + encryptionBytes.toByteArray(access), null, salt + "encryption_key".toByteArray(), METADATA_ENCRYPTION_KEY_SIZE @@ -54,28 +52,64 @@ class MetadataKey(val key: SecretBytes) : Key { 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 { - /** - * 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( seedPhrase: SeedPhrase, network: ZcashNetwork, - account: WalletAccount + zashiAccount: ZashiAccount, + selectedAccount: WalletAccount ): MetadataKey { val key = - DerivationTool.getInstance().deriveArbitraryAccountKey( - contextString = "ZashiMetadataEncryptionV1".toByteArray(), - seed = seedPhrase.toByteArray(), - network = network, - accountIndex = account.hdAccountIndex, - ) - return MetadataKey(SecretBytes.copyFrom(key, InsecureSecretKeyAccess.get())) + DerivationTool.getInstance() + .deriveAccountMetadataKey( + seed = seedPhrase.toByteArray(), + network = network, + accountIndex = zashiAccount.hdAccountIndex, + ) + .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()) + } + ) } } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ResetInMemoryDataUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ResetInMemoryDataUseCase.kt index 791550594..1234f4480 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ResetInMemoryDataUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ResetInMemoryDataUseCase.kt @@ -1,14 +1,11 @@ package co.electriccoin.zcash.ui.common.usecase import co.electriccoin.zcash.ui.common.repository.AddressBookRepository -import co.electriccoin.zcash.ui.common.repository.MetadataRepository class ResetInMemoryDataUseCase( private val addressBookRepository: AddressBookRepository, - private val metadataRepository: MetadataRepository ) { suspend operator fun invoke() { addressBookRepository.resetAddressBook() - metadataRepository.resetMetadata() } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/TransactionDetailView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/TransactionDetailView.kt index e636313c9..8bd0facb5 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/TransactionDetailView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/TransactionDetailView.kt @@ -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.theme.ZcashTheme 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.scaffoldPadding import co.electriccoin.zcash.ui.design.util.stringRes @@ -143,7 +144,7 @@ private fun BottomBar( ) { ZashiBottomBar( isElevated = scrollState.value > 0, - modifier = Modifier.scaffoldPadding(paddingValues, top = 0.dp, bottom = 0.dp) + contentPadding = paddingValues.asScaffoldPaddingValues(top = 0.dp, bottom = 0.dp) ) { Row( modifier = Modifier.fillMaxWidth()