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

@ -208,6 +208,7 @@ android {
packaging { packaging {
resources.pickFirsts += setOf( resources.pickFirsts += setOf(
"META-INF/versions/9/OSGI-INF/MANIFEST.MF", "META-INF/versions/9/OSGI-INF/MANIFEST.MF",
"META-INF/gradle/incremental.annotation.processors",
) )
jniLibs { jniLibs {
@ -315,6 +316,11 @@ licensee {
allowUrl("https://asm.ow2.io/license.html") allowUrl("https://asm.ow2.io/license.html")
allowUrl("https://www.gnu.org/licenses/agpl-3.0.txt") allowUrl("https://www.gnu.org/licenses/agpl-3.0.txt")
allowUrl("https://github.com/mhssn95/compose-color-picker/blob/main/LICENSE") allowUrl("https://github.com/mhssn95/compose-color-picker/blob/main/LICENSE")
allowUrl("https://opensource.org/licenses/mit-license.php")
allowUrl("https://github.com/javaee/javax.annotation/blob/master/LICENSE")
allowUrl("https://www.bouncycastle.org/licence.html")
allowUrl("https://projectlombok.org/LICENSE")
allow("CC0-1.0")
ignoreDependencies("com.github.matrix-org", "matrix-analytics-events") ignoreDependencies("com.github.matrix-org", "matrix-analytics-events")
// Ignore dependency that are not third-party licenses to us. // Ignore dependency that are not third-party licenses to us.
ignoreDependencies(groupId = "io.element.android") ignoreDependencies(groupId = "io.element.android")

View file

@ -52,6 +52,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.duration import io.element.android.features.messages.impl.timeline.model.event.duration
import io.element.android.features.poll.api.create.CreatePollEntryPoint import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.features.wallet.api.WalletEntryPoint
import io.element.android.libraries.architecture.BackstackWithOverlayBox import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
@ -105,6 +106,7 @@ class MessagesFlowNode(
private val shareLocationEntryPoint: ShareLocationEntryPoint, private val shareLocationEntryPoint: ShareLocationEntryPoint,
private val showLocationEntryPoint: ShowLocationEntryPoint, private val showLocationEntryPoint: ShowLocationEntryPoint,
private val createPollEntryPoint: CreatePollEntryPoint, private val createPollEntryPoint: CreatePollEntryPoint,
private val walletEntryPoint: WalletEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint, private val elementCallEntryPoint: ElementCallEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint, private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val forwardEntryPoint: ForwardEntryPoint, private val forwardEntryPoint: ForwardEntryPoint,
@ -179,6 +181,14 @@ class MessagesFlowNode(
@Parcelize @Parcelize
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
@Parcelize
data class PaymentFlow(
val roomId: RoomId,
val recipientUserId: UserId?,
val recipientAddress: String?,
val amountLovelace: Long?,
) : NavTarget
} }
private val callback: MessagesEntryPoint.Callback = callback() private val callback: MessagesEntryPoint.Callback = callback()
@ -293,6 +303,15 @@ class MessagesFlowNode(
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
} }
override fun navigateToPaymentFlow(
roomId: RoomId,
recipientUserId: UserId?,
recipientAddress: String?,
amountLovelace: Long?,
) {
backstack.push(NavTarget.PaymentFlow(roomId, recipientUserId, recipientAddress, amountLovelace))
}
} }
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId) val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
createNode<MessagesNode>(buildContext, listOf(callback, inputs)) createNode<MessagesNode>(buildContext, listOf(callback, inputs))
@ -502,9 +521,39 @@ class MessagesFlowNode(
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
} }
override fun navigateToPaymentFlow(
roomId: RoomId,
recipientUserId: UserId?,
recipientAddress: String?,
amountLovelace: Long?,
) {
backstack.push(NavTarget.PaymentFlow(roomId, recipientUserId, recipientAddress, amountLovelace))
}
} }
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback)) createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
} }
is NavTarget.PaymentFlow -> {
val walletCallback = object : WalletEntryPoint.Callback {
override fun onPaymentSent(txHash: String) {
backstack.pop()
}
override fun onPaymentCancelled() {
backstack.pop()
}
}
walletEntryPoint.paymentFlowBuilder(
parentNode = this,
buildContext = buildContext,
callback = walletCallback,
)
.setRoomId(navTarget.roomId)
.setRecipientUserId(navTarget.recipientUserId)
.setRecipientAddress(navTarget.recipientAddress)
.setAmount(navTarget.amountLovelace?.toString())
.build()
}
} }
} }

View file

@ -130,6 +130,7 @@ class MessagesNode(
fun navigateToRoomDetails() fun navigateToRoomDetails()
fun navigateToPinnedMessagesList() fun navigateToPinnedMessagesList()
fun navigateToKnockRequestsList() fun navigateToKnockRequestsList()
fun navigateToPaymentFlow(roomId: RoomId, recipientUserId: UserId?, recipientAddress: String?, amountLovelace: Long?)
} }
override fun onBuilt() { override fun onBuilt() {
@ -226,6 +227,15 @@ class MessagesNode(
callback.navigateToThread(threadRootId, focusedEventId) callback.navigateToThread(threadRootId, focusedEventId)
} }
override fun navigateToPaymentFlow(
roomId: RoomId,
recipientUserId: UserId?,
recipientAddress: String?,
amountLovelace: Long?,
) {
callback.navigateToPaymentFlow(roomId, recipientUserId, recipientAddress, amountLovelace)
}
private fun displaySameRoomToast() { private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android) context.toast(CommonStrings.screen_room_permalink_same_room_android)
} }

View file

@ -78,6 +78,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPaymentContentWrapper
import io.element.android.features.messages.impl.utils.messagesummary.DefaultMessageSummaryFormatter import io.element.android.features.messages.impl.utils.messagesummary.DefaultMessageSummaryFormatter
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -318,6 +319,9 @@ private fun MessageSummary(
is TimelineItemRtcNotificationContent -> { is TimelineItemRtcNotificationContent -> {
content = { ContentForBody(stringResource(CommonStrings.common_call_started)) } content = { ContentForBody(stringResource(CommonStrings.common_call_started)) }
} }
is TimelineItemPaymentContentWrapper -> {
content = { ContentForBody(textContent) }
}
} }
Row(modifier = modifier) { Row(modifier = modifier) {
icon() icon()

View file

@ -98,6 +98,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@ -345,7 +346,7 @@ class MessageComposerPresenter(
} }
is ResolvedSuggestion.Command -> { is ResolvedSuggestion.Command -> {
// Insert the command text with a trailing space // Insert the command text with a trailing space
richTextEditorState.replaceText("${suggestion.command} ") richTextEditorState.setMarkdown("${suggestion.command} ")
suggestionSearchTrigger.value = null suggestionSearchTrigger.value = null
} }
} }
@ -451,7 +452,7 @@ class MessageComposerPresenter(
when (payCommand) { when (payCommand) {
is io.element.android.features.wallet.impl.slash.ParsedPayCommand.ParseError -> { is io.element.android.features.wallet.impl.slash.ParsedPayCommand.ParseError -> {
// Show error, keep text in composer // Show error, keep text in composer
snackbarDispatcher.post(SnackbarMessage(payCommand.reason)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
return@launch return@launch
} }
is io.element.android.features.wallet.impl.slash.ParsedPayCommand.WithAddressRecipient -> { is io.element.android.features.wallet.impl.slash.ParsedPayCommand.WithAddressRecipient -> {

View file

@ -63,6 +63,7 @@ fun SuggestionsPickerView(
is ResolvedSuggestion.AtRoom -> "@room" is ResolvedSuggestion.AtRoom -> "@room"
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
is ResolvedSuggestion.Alias -> suggestion.roomId.value is ResolvedSuggestion.Alias -> suggestion.roomId.value
is ResolvedSuggestion.Command -> suggestion.command
} }
} }
) { ) {
@ -99,9 +100,11 @@ private fun SuggestionItemView(
is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize) is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize)
is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize) is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize)
is ResolvedSuggestion.Command -> AvatarData(suggestion.command, suggestion.command, null, avatarSize)
} }
val avatarType = when (suggestion) { val avatarType = when (suggestion) {
is ResolvedSuggestion.Alias -> AvatarType.Room() is ResolvedSuggestion.Alias,
is ResolvedSuggestion.Command -> AvatarType.Room()
ResolvedSuggestion.AtRoom, ResolvedSuggestion.AtRoom,
is ResolvedSuggestion.Member -> AvatarType.User is ResolvedSuggestion.Member -> AvatarType.User
} }
@ -109,11 +112,13 @@ private fun SuggestionItemView(
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
is ResolvedSuggestion.Alias -> suggestion.roomName is ResolvedSuggestion.Alias -> suggestion.roomName
is ResolvedSuggestion.Command -> suggestion.command
} }
val subtitle = when (suggestion) { val subtitle = when (suggestion) {
is ResolvedSuggestion.AtRoom -> "@room" is ResolvedSuggestion.AtRoom -> "@room"
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
is ResolvedSuggestion.Alias -> suggestion.roomAlias.value is ResolvedSuggestion.Alias -> suggestion.roomAlias.value
is ResolvedSuggestion.Command -> suggestion.description
} }
Avatar( Avatar(
avatarData = avatarData, avatarData = avatarData,

View file

@ -136,6 +136,7 @@ class ThreadedMessagesNode(
fun navigateToEditPoll(eventId: EventId) fun navigateToEditPoll(eventId: EventId)
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToPaymentFlow(roomId: RoomId, recipientUserId: UserId?, recipientAddress: String?, amountLovelace: Long?)
} }
override fun onBuilt() { override fun onBuilt() {
@ -237,6 +238,15 @@ class ThreadedMessagesNode(
callback.navigateToThread(threadRootId, focusedEventId) callback.navigateToThread(threadRootId, focusedEventId)
} }
override fun navigateToPaymentFlow(
roomId: RoomId,
recipientUserId: UserId?,
recipientAddress: String?,
amountLovelace: Long?,
) {
callback.navigateToPaymentFlow(roomId, recipientUserId, recipientAddress, amountLovelace)
}
override fun close() = navigateUp() override fun close() = navigateUp()
@Composable @Composable

View file

@ -26,8 +26,10 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPaymentContentWrapper
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.CustomEventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
@ -63,6 +65,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
TimelineItemUnknownContent, TimelineItemUnknownContent,
is TimelineItemLegacyCallInviteContent, is TimelineItemLegacyCallInviteContent,
is TimelineItemRtcNotificationContent -> false is TimelineItemRtcNotificationContent -> false
is TimelineItemPaymentContentWrapper -> false
is TimelineItemProfileChangeContent, is TimelineItemProfileChangeContent,
is TimelineItemRoomMembershipContent, is TimelineItemRoomMembershipContent,
is TimelineItemStateEventContent -> true is TimelineItemStateEventContent -> true
@ -91,6 +94,7 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
UnknownContent, UnknownContent,
is LegacyCallInviteContent, is LegacyCallInviteContent,
CallNotifyContent, CallNotifyContent,
is StateContent -> false is StateContent,
is CustomEventContent -> false
} }
} }

View file

@ -83,7 +83,8 @@ fun TimelineItemEventContent.canReact(): Boolean =
is TimelineItemRedactedContent, is TimelineItemRedactedContent,
is TimelineItemLegacyCallInviteContent, is TimelineItemLegacyCallInviteContent,
is TimelineItemRtcNotificationContent, is TimelineItemRtcNotificationContent,
TimelineItemUnknownContent -> false TimelineItemUnknownContent,
is TimelineItemPaymentContentWrapper -> false
} }
/** /**

View file

@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPaymentContentWrapper
/** /**
* Return true if the event must be hidden by default when the setting to hide images and videos is enabled. * Return true if the event must be hidden by default when the setting to hide images and videos is enabled.
@ -53,7 +54,8 @@ fun TimelineItem.mustBeProtected(): Boolean {
is TimelineItemNoticeContent, is TimelineItemNoticeContent,
is TimelineItemTextContent, is TimelineItemTextContent,
TimelineItemUnknownContent, TimelineItemUnknownContent,
is TimelineItemVoiceContent -> false is TimelineItemVoiceContent,
is TimelineItemPaymentContentWrapper -> false
} }
is TimelineItem.Virtual -> false is TimelineItem.Virtual -> false
is TimelineItem.GroupedEvents -> false is TimelineItem.GroupedEvents -> false

View file

@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPaymentContentWrapper
import io.element.android.libraries.core.extensions.toSafeLength import io.element.android.libraries.core.extensions.toSafeLength
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
@ -54,6 +55,7 @@ class DefaultMessageSummaryFormatter(
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio) is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call) is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
is TimelineItemRtcNotificationContent -> context.getString(CommonStrings.common_call_started) is TimelineItemRtcNotificationContent -> context.getString(CommonStrings.common_call_started)
is TimelineItemPaymentContentWrapper -> "Payment"
} }
// Truncate the message to a safe length to avoid crashes in Compose // Truncate the message to a safe length to avoid crashes in Compose
.toSafeLength() .toSafeLength()

View file

@ -6,7 +6,6 @@
package io.element.android.features.wallet.impl.cardano package io.element.android.features.wallet.impl.cardano
import com.bloxbean.cardano.client.account.Account
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject 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 io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber import timber.log.Timber
interface CardanoWalletManager { interface CardanoWalletManager {
@ -24,126 +22,79 @@ interface CardanoWalletManager {
suspend fun initialize(sessionId: SessionId) suspend fun initialize(sessionId: SessionId)
suspend fun getAddress(sessionId: SessionId): Result<String> suspend fun getAddress(sessionId: SessionId): Result<String>
suspend fun getStakeAddress(sessionId: SessionId): Result<String> suspend fun getStakeAddress(sessionId: SessionId): Result<String>
suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int = 0): Result<ByteArray> /** Called by session-scoped components after fetching balance from chain. */
suspend fun refreshBalance(sessionId: SessionId) suspend fun refreshBalance(sessionId: SessionId, balanceLovelace: Long)
fun clearState() 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) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultCardanoWalletManager @Inject constructor( class DefaultCardanoWalletManager @Inject constructor(
private val keyStorage: CardanoKeyStorage, private val keyStorage: CardanoKeyStorage,
private val cardanoClient: io.element.android.features.wallet.api.CardanoClient,
) : CardanoWalletManager { ) : CardanoWalletManager {
private val _walletState = MutableStateFlow(WalletState.Initial) 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) { override suspend fun initialize(sessionId: SessionId) {
_walletState.value = WalletState.Initial.copy(isLoading = true) _walletState.value = WalletState.Initial.copy(isLoading = true)
try { try {
val hasWallet = keyStorage.hasWallet(sessionId) val hasWallet = keyStorage.hasWallet(sessionId)
if (hasWallet) { if (hasWallet) {
val address = keyStorage.getBaseAddress(sessionId).getOrNull() val address = keyStorage.getBaseAddress(sessionId).getOrNull()
_walletState.value = WalletState( _walletState.value = WalletState(
isLoading = false,
hasWallet = true, hasWallet = true,
address = address, address = address,
balanceLovelace = null, balanceLovelace = 0L,
balanceAda = null, balanceAda = "0",
isLoading = false,
error = null, error = null,
) )
Timber.d("Initialized wallet for session: ${sessionId.value}, address: $address")
} else { } else {
_walletState.value = WalletState( _walletState.value = WalletState(
isLoading = false,
hasWallet = false, hasWallet = false,
address = null, address = null,
balanceLovelace = null, balanceLovelace = null,
balanceAda = null, balanceAda = null,
isLoading = false,
error = null, error = null,
) )
Timber.d("No wallet found for session: ${sessionId.value}")
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to initialize wallet for session: ${sessionId.value}") Timber.e(e, "Failed to initialize wallet")
_walletState.value = WalletState( _walletState.value = WalletState(
isLoading = false,
hasWallet = false, hasWallet = false,
address = null, address = null,
balanceLovelace = null, balanceLovelace = null,
balanceAda = 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, 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() { override fun clearState() {
_walletState.value = WalletState.Initial _walletState.value = WalletState.Initial
} }

View file

@ -34,6 +34,13 @@ class TimelineItemContentPaymentFactory {
/** /**
* Check if a message is a payment message. * 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 { fun isPaymentMessage(body: String): Boolean {
return body.startsWith(DefaultPaymentEventSender.PAYMENT_MESSAGE_PREFIX) return body.startsWith(DefaultPaymentEventSender.PAYMENT_MESSAGE_PREFIX)
} }

View file

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

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.CustomEventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
@ -122,6 +123,7 @@ class DefaultRoomLatestEventFormatter(
} }
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call) is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call)
is CallNotifyContent -> sp.getString(CommonStrings.common_call_started) is CallNotifyContent -> sp.getString(CommonStrings.common_call_started)
is CustomEventContent -> null
}?.take(DEFAULT_SAFE_LENGTH) }?.take(DEFAULT_SAFE_LENGTH)
} }

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.CustomEventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
@ -71,7 +72,8 @@ class DefaultTimelineEventFormatter(
is FailedToParseMessageLikeContent, is FailedToParseMessageLikeContent,
is FailedToParseStateContent, is FailedToParseStateContent,
is LiveLocationContent, is LiveLocationContent,
is UnknownContent -> { is UnknownContent,
is CustomEventContent -> {
if (buildMeta.isDebuggable) { if (buildMeta.isDebuggable) {
error("You should not use this formatter for this event content: $content") error("You should not use this formatter for this event content: $content")
} }

View file

@ -13,6 +13,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.CustomEventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
@ -131,5 +132,6 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad
is LegacyCallInviteContent, is LegacyCallInviteContent,
is CallNotifyContent, is CallNotifyContent,
is LiveLocationContent, is LiveLocationContent,
is CustomEventContent,
null -> null null -> null
} }

View file

@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessag
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.CustomEventContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
@ -77,7 +78,8 @@ class EventItemFactory(
is StickerContent, is StickerContent,
is UnableToDecryptContent, is UnableToDecryptContent,
is LiveLocationContent, is LiveLocationContent,
UnknownContent -> { UnknownContent,
is CustomEventContent -> {
Timber.w("Should not happen: ${content.javaClass.simpleName}") Timber.w("Should not happen: ${content.javaClass.simpleName}")
null null
} }