Message visibility based on foreground/background

This commit is contained in:
Milan Cerovsky 2025-04-11 14:10:43 +02:00
parent c45d672995
commit 1315ba04ec
16 changed files with 468 additions and 301 deletions

View File

@ -19,6 +19,7 @@ import co.electriccoin.zcash.spackle.StrictModeCompat
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider
import co.electriccoin.zcash.ui.common.repository.FlexaRepository
import co.electriccoin.zcash.ui.common.repository.HomeMessageCacheRepository
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
@ -31,6 +32,7 @@ class ZcashApplication : CoroutineApplication() {
private val flexaRepository by inject<FlexaRepository>()
private val applicationStateProvider: ApplicationStateProvider by inject()
private val getAvailableCrashReporters: CrashReportersProvider by inject()
private val homeMessageCacheRepository: HomeMessageCacheRepository by inject()
override fun onCreate() {
super.onCreate()
@ -68,6 +70,7 @@ class ZcashApplication : CoroutineApplication() {
configureAnalytics()
flexaRepository.init()
homeMessageCacheRepository.init()
}
private fun configureLogging() {

View File

@ -8,6 +8,8 @@ import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.FlexaRepository
import co.electriccoin.zcash.ui.common.repository.FlexaRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.HomeMessageCacheRepository
import co.electriccoin.zcash.ui.common.repository.HomeMessageCacheRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepository
import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.ShieldFundsRepository
@ -36,4 +38,5 @@ val repositoryModule =
singleOf(::TransactionFilterRepositoryImpl) bind TransactionFilterRepository::class
singleOf(::ZashiProposalRepositoryImpl) bind ZashiProposalRepository::class
singleOf(::ShieldFundsRepositoryImpl) bind ShieldFundsRepository::class
singleOf(::HomeMessageCacheRepositoryImpl) bind HomeMessageCacheRepository::class
}

View File

@ -185,7 +185,7 @@ val useCaseModule =
factoryOf(::GetKeystoneStatusUseCase)
factoryOf(::GetCoinbaseStatusUseCase)
factoryOf(::GetFlexaStatusUseCase)
factoryOf(::GetHomeMessageUseCase)
singleOf(::GetHomeMessageUseCase)
factoryOf(::OnUserSavedWalletBackupUseCase)
factoryOf(::RemindWalletBackupLaterUseCase)
factoryOf(::RemindShieldFundsLaterUseCase)

View File

@ -34,6 +34,7 @@ import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT
import co.electriccoin.zcash.ui.NavigationTargets.WHATS_NEW
import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.datasource.MessageAvailabilityDataSource
import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider
import co.electriccoin.zcash.ui.common.provider.isInForeground
import co.electriccoin.zcash.ui.design.LocalKeyboardManager
@ -141,6 +142,7 @@ internal fun MainActivity.Navigation() {
val flexaViewModel = koinViewModel<FlexaViewModel>()
val navigationRouter = koinInject<NavigationRouter>()
val sheetStateManager = LocalSheetStateManager.current
val messageAvailabilityDataSource = koinInject<MessageAvailabilityDataSource>()
// Helper properties for triggering the system security UI from callbacks
val (exportPrivateDataAuthentication, setExportPrivateDataAuthentication) =
@ -153,14 +155,16 @@ internal fun MainActivity.Navigation() {
navController,
flexaViewModel,
keyboardManager,
sheetStateManager
sheetStateManager,
messageAvailabilityDataSource
) {
NavigatorImpl(
activity = this@Navigation,
navController = navController,
flexaViewModel = flexaViewModel,
keyboardManager = keyboardManager,
sheetStateManager = sheetStateManager
sheetStateManager = sheetStateManager,
messageAvailabilityDataSource = messageAvailabilityDataSource
)
}

View File

@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.serialization.generateHashCode
import co.electriccoin.zcash.ui.common.datasource.MessageAvailabilityDataSource
import co.electriccoin.zcash.ui.design.KeyboardManager
import co.electriccoin.zcash.ui.design.SheetStateManager
import co.electriccoin.zcash.ui.screen.ExternalUrl
@ -25,6 +26,7 @@ class NavigatorImpl(
private val flexaViewModel: FlexaViewModel,
private val keyboardManager: KeyboardManager,
private val sheetStateManager: SheetStateManager,
private val messageAvailabilityDataSource: MessageAvailabilityDataSource,
) : Navigator {
override suspend fun executeCommand(command: NavigationCommand) {
keyboardManager.close()
@ -85,6 +87,7 @@ class NavigatorImpl(
throw UnsupportedOperationException("External url can be opened as last screen only")
}
messageAvailabilityDataSource.onThirdPartyUiShown()
WebBrowserUtil.startActivity(activity, route.url)
}
@ -125,6 +128,7 @@ class NavigatorImpl(
throw UnsupportedOperationException("External url can be opened as last screen only")
}
messageAvailabilityDataSource.onThirdPartyUiShown()
WebBrowserUtil.startActivity(activity, route.url)
}
@ -149,6 +153,10 @@ class NavigatorImpl(
else -> navController.executeNavigation(route = route)
}
}
if (command.routes.lastOrNull() in listOf(ExternalUrl, co.electriccoin.zcash.ui.screen.flexa.Flexa) ) {
messageAvailabilityDataSource.onThirdPartyUiShown()
}
}
private fun NavHostController.executeNavigation(
@ -171,11 +179,11 @@ class NavigatorImpl(
}
private fun createFlexaFlow(flexaViewModel: FlexaViewModel) {
messageAvailabilityDataSource.onThirdPartyUiShown()
Flexa
.buildSpend()
.onTransactionRequest { result ->
flexaViewModel.createTransaction(result)
}.build()
.onTransactionRequest { result -> flexaViewModel.createTransaction(result) }
.build()
.open(activity)
}
}

View File

@ -5,42 +5,75 @@ import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
interface MessageAvailabilityDataSource {
val canShowMessage: Boolean
fun observe(): StateFlow<Boolean>
fun observe(): Flow<Boolean>
fun onMessageShown()
fun onThirdPartyUiShown()
}
class MessageAvailabilityDataSourceImpl(
private val applicationStateProvider: ApplicationStateProvider
): MessageAvailabilityDataSource {
applicationStateProvider: ApplicationStateProvider
) : MessageAvailabilityDataSource {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val state = MutableStateFlow(true)
private val state = MutableStateFlow(
MessageAvailabilityData(
isAppInForeground = true,
isThirdPartyUiShown = false,
hasMessageBeenShown = false
)
)
override val canShowMessage: Boolean
get() = state.value
get() = state.value.canShowMessage
init {
scope.launch {
applicationStateProvider.state.collect {
if (it == Lifecycle.Event.ON_START) {
state.update { true }
applicationStateProvider.state
.onEach { event ->
if (event == Lifecycle.Event.ON_START) {
state.update {
it.copy(
isAppInForeground = true,
hasMessageBeenShown = if (it.isThirdPartyUiShown) it.hasMessageBeenShown else false,
isThirdPartyUiShown = false
)
}
} else if (event == Lifecycle.Event.ON_STOP) {
state.update {
it.copy(
isAppInForeground = it.isThirdPartyUiShown,
)
}
}
}
}
.launchIn(scope)
}
override fun observe(): StateFlow<Boolean> = state.asStateFlow()
override fun observe(): Flow<Boolean> = state.map { it.canShowMessage }.distinctUntilChanged()
override fun onMessageShown() {
state.update { false }
state.update { it.copy(hasMessageBeenShown = true) }
}
override fun onThirdPartyUiShown() {
state.update { it.copy(isThirdPartyUiShown = true) }
}
}
private data class MessageAvailabilityData(
val isAppInForeground: Boolean,
val isThirdPartyUiShown: Boolean,
val hasMessageBeenShown: Boolean
) {
val canShowMessage = isAppInForeground && !hasMessageBeenShown
}

View File

@ -10,12 +10,7 @@ import co.electriccoin.zcash.ui.common.viewmodel.SynchronizerError
// TODO [#292]: Should be moved to SDK-EXT-UI module.
// TODO [#292]: https://github.com/Electric-Coin-Company/zashi-android/issues/292
data class WalletSnapshot(
val isZashi: Boolean,
val status: Synchronizer.Status,
val processorInfo: CompactBlockProcessor.ProcessorInfo,
val orchardBalance: WalletBalance,
val saplingBalance: WalletBalance?,
val transparentBalance: Zatoshi,
val progress: PercentDecimal,
val synchronizerError: SynchronizerError?
)

View File

@ -0,0 +1,88 @@
package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.common.datasource.MessageAvailabilityDataSource
import co.electriccoin.zcash.ui.common.viewmodel.SynchronizerError
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
interface HomeMessageCacheRepository {
/**
* Last message that was shown. Null if no message has been shown yet.
*/
var lastShownMessage: HomeMessageData?
/**
* Last message that was shown. Null if no message has been shown yet or if last message was null.
*/
var lastMessage: HomeMessageData?
fun init()
fun reset()
}
class HomeMessageCacheRepositoryImpl(
private val messageAvailabilityDataSource: MessageAvailabilityDataSource
) : HomeMessageCacheRepository {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override var lastShownMessage: HomeMessageData? = null
override var lastMessage: HomeMessageData? = null
override fun init() {
messageAvailabilityDataSource
.observe()
.onEach { canShowMessage ->
if (canShowMessage) {
lastShownMessage = null
lastMessage = null
}
}
.launchIn(scope)
}
override fun reset() {
lastShownMessage = null
lastMessage = null
}
}
sealed interface HomeMessageData {
val priority: Int
data class Error(val synchronizerError: SynchronizerError) : RuntimeMessage()
data object Disconnected : RuntimeMessage()
data class Restoring(val progress: Float) : RuntimeMessage()
data class Syncing(val progress: Float) : RuntimeMessage()
data object Updating : RuntimeMessage()
data object Backup : Prioritized {
override val priority: Int = 3
}
data class ShieldFunds(val zatoshi: Zatoshi) : Prioritized {
override val priority: Int = 2
}
data object EnableCurrencyConversion : Prioritized {
override val priority: Int = 1
}
}
/**
* Message which always is shown.
*/
sealed class RuntimeMessage : HomeMessageData {
override val priority: Int = 4
}
/**
* Message which always is displayed only if previous message was lower priority.
*/
sealed interface Prioritized : HomeMessageData

View File

@ -1,5 +1,7 @@
package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.AccountUuid
import cash.z.ecc.android.sdk.model.TransactionId
import cash.z.ecc.android.sdk.model.TransactionOutput
import cash.z.ecc.android.sdk.model.TransactionOverview
@ -8,6 +10,7 @@ import cash.z.ecc.android.sdk.model.TransactionState.Expired
import cash.z.ecc.android.sdk.model.TransactionState.Pending
import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.model.ZashiAccount
import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -20,6 +23,7 @@ import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
@ -38,6 +42,8 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
interface TransactionRepository {
val zashiTransactions: Flow<List<Transaction>?>
val currentTransactions: Flow<List<Transaction>?>
suspend fun getMemos(transaction: Transaction): List<String>
@ -52,21 +58,49 @@ interface TransactionRepository {
}
class TransactionRepositoryImpl(
accountDataSource: AccountDataSource,
private val accountDataSource: AccountDataSource,
private val synchronizerProvider: SynchronizerProvider,
) : TransactionRepository {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override val zashiTransactions: Flow<List<Transaction>?> =
observeTransactions(
accountFlow = accountDataSource.zashiAccount.map { it?.sdkAccount?.accountUuid }.distinctUntilChanged()
).stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5.seconds, Duration.ZERO),
initialValue = null
)
@OptIn(ExperimentalCoroutinesApi::class)
override val currentTransactions: Flow<List<Transaction>?> =
override val currentTransactions: Flow<List<Transaction>?> = accountDataSource.selectedAccount
.distinctUntilChangedBy { it?.sdkAccount?.accountUuid }
.flatMapLatest { selected ->
if (selected is ZashiAccount) {
zashiTransactions
} else {
observeTransactions(
accountFlow = accountDataSource.selectedAccount.map { it?.sdkAccount?.accountUuid }
.distinctUntilChanged()
)
}
}
.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5.seconds, Duration.ZERO),
initialValue = null
)
@OptIn(ExperimentalCoroutinesApi::class)
private fun TransactionRepositoryImpl.observeTransactions(accountFlow: Flow<AccountUuid?>) =
combine(
synchronizerProvider.synchronizer,
accountDataSource.selectedAccount.map { it?.sdkAccount }
accountFlow
) { synchronizer, account ->
synchronizer to account
}.distinctUntilChanged()
.flatMapLatest { (synchronizer, account) ->
if (synchronizer == null || account == null) {
.flatMapLatest { (synchronizer, accountUuid) ->
if (synchronizer == null || accountUuid == null) {
flowOf(null)
} else {
channelFlow<List<Transaction>?> {
@ -74,144 +108,9 @@ class TransactionRepositoryImpl(
launch {
synchronizer
.getTransactions(account.accountUuid)
.getTransactions(accountUuid)
.mapLatest { transactions ->
transactions
.map { transaction ->
when (transaction.transactionState) {
Expired ->
when {
transaction.isShielding ->
ShieldTransaction.Failed(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.totalSpent,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.netValue,
overview = transaction
)
transaction.isSentTransaction ->
SendTransaction.Failed(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.feePaid,
overview = transaction
)
else ->
ReceiveTransaction.Failed(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
overview = transaction
)
}
Confirmed ->
when {
transaction.isShielding ->
ShieldTransaction.Success(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.totalSpent,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.netValue,
overview = transaction
)
transaction.isSentTransaction ->
SendTransaction.Success(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.feePaid,
overview = transaction
)
else ->
ReceiveTransaction.Success(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
overview = transaction
)
}
Pending ->
when {
transaction.isShielding ->
ShieldTransaction.Pending(
timestamp = createTimestamp(transaction),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.totalSpent,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.netValue,
overview = transaction
)
transaction.isSentTransaction ->
SendTransaction.Pending(
timestamp = createTimestamp(transaction),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.feePaid,
overview = transaction
)
else ->
ReceiveTransaction.Pending(
timestamp = createTimestamp(transaction),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
overview = transaction
)
}
else -> error("Unexpected transaction stat")
}
}.sortedByDescending { transaction ->
transaction.timestamp ?: Instant.now()
}
createTransactions(transactions = transactions, synchronizer = synchronizer)
}.collect {
send(it)
}
@ -222,11 +121,147 @@ class TransactionRepositoryImpl(
}
}
}
}.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5.seconds, Duration.ZERO),
initialValue = null
)
}
private suspend fun createTransactions(
transactions: List<TransactionOverview>,
synchronizer: Synchronizer
) = transactions
.map { transaction ->
when (transaction.transactionState) {
Expired ->
when {
transaction.isShielding ->
ShieldTransaction.Failed(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.totalSpent,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.netValue,
overview = transaction
)
transaction.isSentTransaction ->
SendTransaction.Failed(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.feePaid,
overview = transaction
)
else ->
ReceiveTransaction.Failed(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
overview = transaction
)
}
Confirmed ->
when {
transaction.isShielding ->
ShieldTransaction.Success(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.totalSpent,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.netValue,
overview = transaction
)
transaction.isSentTransaction ->
SendTransaction.Success(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.feePaid,
overview = transaction
)
else ->
ReceiveTransaction.Success(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
overview = transaction
)
}
Pending ->
when {
transaction.isShielding ->
ShieldTransaction.Pending(
timestamp = createTimestamp(transaction),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.totalSpent,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.netValue,
overview = transaction
)
transaction.isSentTransaction ->
SendTransaction.Pending(
timestamp = createTimestamp(transaction),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.feePaid,
overview = transaction
)
else ->
ReceiveTransaction.Pending(
timestamp = createTimestamp(transaction),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
overview = transaction
)
}
else -> error("Unexpected transaction stat")
}
}.sortedByDescending { transaction ->
transaction.timestamp ?: Instant.now()
}
private fun createTimestamp(transaction: TransactionOverview): Instant? =
transaction.blockTimeEpochSeconds?.let {

View File

@ -4,7 +4,6 @@ import android.app.Application
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.FastestServersResult
import cash.z.ecc.android.sdk.model.PercentDecimal
@ -23,7 +22,6 @@ import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.model.ZashiAccount
import co.electriccoin.zcash.ui.common.provider.GetDefaultServersProvider
import co.electriccoin.zcash.ui.common.provider.PersistableWalletProvider
import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider
@ -143,6 +141,7 @@ class WalletRepositoryImpl(
OnboardingState.NEEDS_WARN,
OnboardingState.NEEDS_BACKUP,
OnboardingState.NONE -> SecretState.NONE
OnboardingState.READY -> SecretState.READY
}
}
@ -190,13 +189,11 @@ class WalletRepositoryImpl(
@OptIn(ExperimentalCoroutinesApi::class)
override val currentWalletSnapshot: StateFlow<WalletSnapshot?> =
combine(synchronizer, currentAccount) { synchronizer, currentAccount ->
synchronizer to currentAccount
}.flatMapLatest { (synchronizer, currentAccount) ->
if (synchronizer == null || currentAccount == null) {
synchronizer.flatMapLatest { synchronizer ->
if (synchronizer == null) {
flowOf(null)
} else {
toWalletSnapshot(synchronizer, currentAccount)
toWalletSnapshot(synchronizer)
}
}.stateIn(
scope = scope,
@ -355,29 +352,14 @@ private fun Synchronizer.toCommonError(): Flow<SynchronizerError?> =
// No good way around needing magic numbers for the indices
@Suppress("MagicNumber")
private fun toWalletSnapshot(
synchronizer: Synchronizer,
account: WalletAccount
) = combine(
// 0
synchronizer.status,
// 1
synchronizer.processorInfo,
// 2
synchronizer.progress,
// 3
synchronizer.toCommonError()
private fun toWalletSnapshot(synchronizer: Synchronizer) = combine(
synchronizer.status, // 0
synchronizer.progress, // 1
synchronizer.toCommonError() // 2
) { flows ->
val progressPercentDecimal = (flows[2] as PercentDecimal)
WalletSnapshot(
isZashi = account is ZashiAccount,
status = flows[0] as Synchronizer.Status,
processorInfo = flows[1] as CompactBlockProcessor.ProcessorInfo,
orchardBalance = account.unified.balance,
saplingBalance = account.sapling?.balance,
transparentBalance = account.transparent.balance,
progress = progressPercentDecimal,
synchronizerError = flows[3] as SynchronizerError?
progress = flows[1] as PercentDecimal,
synchronizerError = flows[2] as SynchronizerError?
)
}

View File

@ -1,15 +1,22 @@
package co.electriccoin.zcash.ui.common.usecase
import android.util.Log
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.datasource.MessageAvailabilityDataSource
import co.electriccoin.zcash.ui.common.datasource.WalletBackupAvailability
import co.electriccoin.zcash.ui.common.datasource.WalletBackupDataSource
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.repository.HomeMessageCacheRepository
import co.electriccoin.zcash.ui.common.repository.HomeMessageData
import co.electriccoin.zcash.ui.common.repository.ReceiveTransaction
import co.electriccoin.zcash.ui.common.repository.RuntimeMessage
import co.electriccoin.zcash.ui.common.repository.ShieldFundsData
import co.electriccoin.zcash.ui.common.repository.ShieldFundsRepository
import co.electriccoin.zcash.ui.common.repository.TransactionRepository
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.viewmodel.SynchronizerError
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
@ -18,69 +25,117 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlin.time.Duration.Companion.seconds
class GetHomeMessageUseCase(
private val walletRepository: WalletRepository,
private val walletBackupDataSource: WalletBackupDataSource,
private val exchangeRateRepository: ExchangeRateRepository,
private val shieldFundsRepository: ShieldFundsRepository,
walletRepository: WalletRepository,
walletBackupDataSource: WalletBackupDataSource,
exchangeRateRepository: ExchangeRateRepository,
shieldFundsRepository: ShieldFundsRepository,
transactionRepository: TransactionRepository,
private val messageAvailabilityDataSource: MessageAvailabilityDataSource,
private val cache: HomeMessageCacheRepository,
) {
private val backupFlow = combine(
transactionRepository.zashiTransactions,
walletBackupDataSource.observe()
) { transactions, backup ->
if (backup is WalletBackupAvailability.Available && transactions.orEmpty().any { it is ReceiveTransaction }) {
backup
} else {
WalletBackupAvailability.Unavailable
}
}.distinctUntilChanged()
@OptIn(FlowPreview::class)
fun observe(): Flow<HomeMessageData?> = combine(
private val flow = combine(
walletRepository.currentWalletSnapshot.filterNotNull(),
walletRepository.walletRestoringState,
walletBackupDataSource.observe(),
walletRepository.walletRestoringState.filterNotNull(),
backupFlow,
exchangeRateRepository.state.map { it == ExchangeRateState.OptIn }.distinctUntilChanged(),
shieldFundsRepository.availability
) { walletSnapshot, walletStateInformation, backup, isCCAvailable, shieldFunds ->
when {
walletSnapshot.synchronizerError != null -> {
HomeMessageData.Error(walletSnapshot.synchronizerError)
}
createMessage(walletSnapshot, walletStateInformation, backup, shieldFunds, isCCAvailable)
}.debounce(.5.seconds)
.map { message -> prioritizeMessage(message) }
walletSnapshot.status == Synchronizer.Status.DISCONNECTED -> {
HomeMessageData.Disconnected
}
fun observe(): Flow<HomeMessageData?> = flow
walletSnapshot.status in listOf(
Synchronizer.Status.INITIALIZING,
Synchronizer.Status.SYNCING,
Synchronizer.Status.STOPPED
) -> {
val progress = walletSnapshot.progress.decimal * 100f
val result = when {
walletStateInformation == WalletRestoringState.RESTORING -> {
HomeMessageData.Restoring(
progress = progress,
)
}
private fun createMessage(
walletSnapshot: WalletSnapshot,
walletStateInformation: WalletRestoringState,
backup: WalletBackupAvailability,
shieldFunds: ShieldFundsData,
isCCAvailable: Boolean
) = when {
walletSnapshot.synchronizerError != null -> HomeMessageData.Error(walletSnapshot.synchronizerError)
else -> {
HomeMessageData.Syncing(progress = progress)
}
walletSnapshot.status == Synchronizer.Status.DISCONNECTED -> HomeMessageData.Disconnected
walletSnapshot.status in listOf(
Synchronizer.Status.INITIALIZING,
Synchronizer.Status.SYNCING,
Synchronizer.Status.STOPPED
) -> {
val progress = walletSnapshot.progress.decimal * 100f
val result = when {
walletStateInformation == WalletRestoringState.RESTORING -> {
HomeMessageData.Restoring(
progress = progress,
)
}
else -> {
HomeMessageData.Syncing(progress = progress)
}
result
}
result
}
shieldFunds is ShieldFundsData.Available -> HomeMessageData.ShieldFunds(shieldFunds.amount)
backup is WalletBackupAvailability.Available -> HomeMessageData.Backup
backup is WalletBackupAvailability.Available -> HomeMessageData.Backup
shieldFunds is ShieldFundsData.Available -> HomeMessageData.ShieldFunds(shieldFunds.amount)
isCCAvailable -> HomeMessageData.EnableCurrencyConversion
isCCAvailable -> HomeMessageData.EnableCurrencyConversion
else -> null
}
private fun prioritizeMessage(message: HomeMessageData?): HomeMessageData? {
val isSameMessageUpdate = message?.priority == cache.lastMessage?.priority // same but updated
val someMessageBeenShown = cache.lastShownMessage != null // has any message been shown while app in fg
val hasNoMessageBeenShownLately = cache.lastMessage == null // has no message been shown
val isHigherPriorityMessage = (message?.priority ?: 0) > (cache.lastShownMessage?.priority ?: 0)
val result = when {
message == null -> null
message is RuntimeMessage -> message
isSameMessageUpdate -> message
isHigherPriorityMessage -> if (hasNoMessageBeenShownLately) {
if (someMessageBeenShown) null else message
} else {
message
}
else -> null
}
}.debounce(.5.seconds)
}
sealed interface HomeMessageData {
data object EnableCurrencyConversion : HomeMessageData
data class ShieldFunds(val zatoshi: Zatoshi) : HomeMessageData
data object Backup : HomeMessageData
data object Disconnected : HomeMessageData
data class Error(val synchronizerError: SynchronizerError) : HomeMessageData
data class Restoring(val progress: Float) : HomeMessageData
data class Syncing(val progress: Float) : HomeMessageData
data object Updating : HomeMessageData
if (result != null) {
messageAvailabilityDataSource.onMessageShown()
cache.lastShownMessage = result
}
cache.lastMessage = result
Twig.debug {
when {
message == null -> "Home message: no message to show"
result == null -> "Home message: ${message::class.simpleName} was filtered out"
else -> {
"Home message: ${result::class.simpleName} shown"
}
}
}
return result
}
}

View File

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

View File

@ -1,46 +0,0 @@
package co.electriccoin.zcash.ui.fixture
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.fixture.WalletBalanceFixture
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.fixture.ZatoshiFixture
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.viewmodel.SynchronizerError
@Suppress("MagicNumber")
object WalletSnapshotFixture {
val STATUS = Synchronizer.Status.SYNCED
val PROGRESS = PercentDecimal.ZERO_PERCENT
val TRANSPARENT_BALANCE: Zatoshi = ZatoshiFixture.new(8)
val ORCHARD_BALANCE: WalletBalance = WalletBalanceFixture.newLong(8, 2, 1)
val SAPLING_BALANCE: WalletBalance = WalletBalanceFixture.newLong(5, 2, 1)
// Should fill in with non-empty values for better example values in tests and UI previews
@Suppress("LongParameterList")
fun new(
status: Synchronizer.Status = STATUS,
processorInfo: CompactBlockProcessor.ProcessorInfo =
CompactBlockProcessor.ProcessorInfo(
null,
null,
null
),
orchardBalance: WalletBalance = ORCHARD_BALANCE,
saplingBalance: WalletBalance = SAPLING_BALANCE,
transparentBalance: Zatoshi = TRANSPARENT_BALANCE,
progress: PercentDecimal = PROGRESS,
synchronizerError: SynchronizerError? = null
) = WalletSnapshot(
status = status,
processorInfo = processorInfo,
orchardBalance = orchardBalance,
saplingBalance = saplingBalance,
transparentBalance = transparentBalance,
progress = progress,
synchronizerError = synchronizerError,
isZashi = false,
)
}

View File

@ -12,7 +12,7 @@ import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.usecase.GetHomeMessageUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSelectedWalletAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.HomeMessageData
import co.electriccoin.zcash.ui.common.repository.HomeMessageData
import co.electriccoin.zcash.ui.common.usecase.IsRestoreSuccessDialogVisibleUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase
import co.electriccoin.zcash.ui.common.usecase.ShieldFundsUseCase
@ -246,6 +246,6 @@ class HomeViewModel(
// ),
// fullStackTrace = walletSnapshot.synchronizerError.getStackTrace(limit = null)
// )
TODO()
// TODO()
}
}

View File

@ -23,6 +23,7 @@ import co.electriccoin.zcash.ui.Navigator
import co.electriccoin.zcash.ui.NavigatorImpl
import co.electriccoin.zcash.ui.common.compose.LocalActivity
import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.datasource.MessageAvailabilityDataSource
import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
@ -50,25 +51,29 @@ import org.koin.compose.koinInject
@Composable
fun MainActivity.OnboardingNavigation() {
val activity = LocalActivity.current
val navigationRouter = koinInject<NavigationRouter>()
val navController = LocalNavController.current
val keyboardManager = LocalKeyboardManager.current
val flexaViewModel = koinViewModel<FlexaViewModel>()
val sheetStateManager = LocalSheetStateManager.current
val navigationRouter = koinInject<NavigationRouter>()
val flexaViewModel = koinViewModel<FlexaViewModel>()
val messageAvailabilityDataSource = koinInject<MessageAvailabilityDataSource>()
val navigator: Navigator =
remember(
navController,
flexaViewModel,
keyboardManager,
sheetStateManager
sheetStateManager,
messageAvailabilityDataSource
) {
NavigatorImpl(
activity = this@OnboardingNavigation,
navController = navController,
flexaViewModel = flexaViewModel,
keyboardManager = keyboardManager,
sheetStateManager = sheetStateManager
sheetStateManager = sheetStateManager,
messageAvailabilityDataSource = messageAvailabilityDataSource
)
}

View File

@ -77,8 +77,7 @@ class TransactionHistoryWidgetViewModel(
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue =
TransactionHistoryWidgetState.Loading
initialValue = TransactionHistoryWidgetState.Loading
)
private fun onTransactionClick(transaction: Transaction) {