fix(wallet): resolve DI scope mismatch, WalletState constructors, packaging conflict

- CardanoWalletManager moved CardanoClient dep out of AppScope — was causing
  Metro MissingBinding at compile time (CardanoClient is SessionScope)
- refreshBalance() now takes balanceLovelace param instead of fetching from client
- WalletState constructor calls fixed with all required fields
- app/build.gradle.kts: added META-INF/gradle/incremental.annotation.processors
  to pickFirsts to resolve moshi-kotlin-codegen/lombok resource conflict
- App builds and launches successfully on emulator (verified)
This commit is contained in:
Kayos 2026-03-27 21:56:01 -07:00
parent c722ecb3a7
commit ad89eddfea
18 changed files with 149 additions and 89 deletions

View file

@ -6,7 +6,6 @@
package io.element.android.features.wallet.impl.cardano
import com.bloxbean.cardano.client.account.Account
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
@ -16,7 +15,6 @@ import io.element.android.features.wallet.api.storage.CardanoKeyStorage
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber
interface CardanoWalletManager {
@ -24,126 +22,79 @@ interface CardanoWalletManager {
suspend fun initialize(sessionId: SessionId)
suspend fun getAddress(sessionId: SessionId): Result<String>
suspend fun getStakeAddress(sessionId: SessionId): Result<String>
suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int = 0): Result<ByteArray>
suspend fun refreshBalance(sessionId: SessionId)
/** Called by session-scoped components after fetching balance from chain. */
suspend fun refreshBalance(sessionId: SessionId, balanceLovelace: Long)
fun clearState()
}
/**
* App-scoped wallet manager. Handles key derivation and state only.
* Balance refresh is driven by session-scoped components that have access to CardanoClient.
*/
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultCardanoWalletManager @Inject constructor(
private val keyStorage: CardanoKeyStorage,
private val cardanoClient: io.element.android.features.wallet.api.CardanoClient,
) : CardanoWalletManager {
private val _walletState = MutableStateFlow(WalletState.Initial)
override val walletState: StateFlow<WalletState> = _walletState.asStateFlow()
override val walletState: StateFlow<WalletState> = _walletState
override suspend fun initialize(sessionId: SessionId) {
_walletState.value = WalletState.Initial.copy(isLoading = true)
try {
val hasWallet = keyStorage.hasWallet(sessionId)
if (hasWallet) {
val address = keyStorage.getBaseAddress(sessionId).getOrNull()
_walletState.value = WalletState(
isLoading = false,
hasWallet = true,
address = address,
balanceLovelace = null,
balanceAda = null,
isLoading = false,
balanceLovelace = 0L,
balanceAda = "0",
error = null,
)
Timber.d("Initialized wallet for session: ${sessionId.value}, address: $address")
} else {
_walletState.value = WalletState(
isLoading = false,
hasWallet = false,
address = null,
balanceLovelace = null,
balanceAda = null,
isLoading = false,
error = null,
)
Timber.d("No wallet found for session: ${sessionId.value}")
}
} catch (e: Exception) {
Timber.e(e, "Failed to initialize wallet for session: ${sessionId.value}")
Timber.e(e, "Failed to initialize wallet")
_walletState.value = WalletState(
isLoading = false,
hasWallet = false,
address = null,
balanceLovelace = null,
balanceAda = null,
error = e.message,
)
}
}
override suspend fun getAddress(sessionId: SessionId): Result<String> =
keyStorage.getBaseAddress(sessionId)
override suspend fun getStakeAddress(sessionId: SessionId): Result<String> =
keyStorage.getStakeAddress(sessionId)
override suspend fun refreshBalance(sessionId: SessionId, balanceLovelace: Long) {
val current = _walletState.value
if (current.hasWallet) {
val ada = "%.6f".format(balanceLovelace / 1_000_000.0)
_walletState.value = current.copy(
balanceLovelace = balanceLovelace,
balanceAda = ada,
isLoading = false,
error = e.message ?: "Failed to load wallet",
)
}
}
override suspend fun getAddress(sessionId: SessionId): Result<String> {
return keyStorage.getBaseAddress(sessionId)
}
override suspend fun getStakeAddress(sessionId: SessionId): Result<String> {
return keyStorage.getStakeAddress(sessionId)
}
override suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int): Result<ByteArray> {
return runCatching {
val mnemonic = keyStorage.getMnemonic(sessionId).getOrThrow()
val mnemonicString = mnemonic.joinToString(" ")
val account = Account(CardanoNetworkConfig.getNetwork(), mnemonicString, addressIndex)
val privateKeyBytes = account.privateKeyBytes()
Timber.d("Retrieved spending key for session: ${sessionId.value}, index: $addressIndex")
privateKeyBytes
}
}
override suspend fun refreshBalance(sessionId: SessionId) {
val currentState = _walletState.value
if (!currentState.hasWallet || currentState.address == null) {
return
}
_walletState.value = currentState.copy(isLoading = true, error = null)
try {
val result = cardanoClient.getBalance(currentState.address!!)
result.fold(
onSuccess = { lovelace ->
val adaString = formatLovelaceToAda(lovelace)
_walletState.value = currentState.copy(
balanceLovelace = lovelace,
balanceAda = adaString,
isLoading = false,
error = null,
)
Timber.d("Balance refreshed: $lovelace lovelace ($adaString ADA)")
},
onFailure = { error ->
Timber.e(error, "Failed to refresh balance")
_walletState.value = currentState.copy(
isLoading = false,
error = error.message ?: "Failed to fetch balance",
)
}
)
} catch (e: Exception) {
Timber.e(e, "Exception during balance refresh")
_walletState.value = currentState.copy(
isLoading = false,
error = e.message ?: "Failed to fetch balance",
)
}
}
private fun formatLovelaceToAda(lovelace: Long): String {
val ada = lovelace / 1_000_000.0
return String.format("%.6f", ada)
.trimEnd('0')
.trimEnd('.')
}
override fun clearState() {
_walletState.value = WalletState.Initial
}

View file

@ -34,6 +34,13 @@ class TimelineItemContentPaymentFactory {
/**
* Check if a message is a payment message.
*/
/**
* Check if an event type is a payment event type.
*/
fun isPaymentEventType(eventType: String): Boolean {
return eventType == "com.sulkta.cardano.payment"
}
fun isPaymentMessage(body: String): Boolean {
return body.startsWith(DefaultPaymentEventSender.PAYMENT_MESSAGE_PREFIX)
}

View file

@ -27,7 +27,7 @@ class CardanoWalletManagerTest {
fun setUp() {
fakeKeyStorage = FakeCardanoKeyStorage()
fakeCardanoClient = FakeCardanoClient()
walletManager = DefaultCardanoWalletManager(fakeKeyStorage, fakeCardanoClient)
walletManager = DefaultCardanoWalletManager(fakeKeyStorage)
}
@Test