feat(wallet): scaffold wallet module structure

Task 1 of Phase 1 - Module Scaffolding

- Created features/wallet/api module with WalletEntryPoint and WalletState
- Created features/wallet/impl module with Metro DI setup
- Created features/wallet/test module with FakeWalletEntryPoint
- Added PaymentFlowNode placeholder with Appyx navigation
- Added Cardano client library dependencies (0.7.1)
- Added proguard rules for Cardano library
- Added basic unit tests for WalletState

The module follows Element X patterns:
- Metro for dependency injection (@ContributesTo, @ContributesBinding, @ContributesNode)
- Appyx for navigation (BaseFlowNode pattern)
- api/impl/test module separation
- Feature entry point pattern for navigation

This module scaffolding blocks all subsequent tasks (2-8) in Phase 1.
This commit is contained in:
Kayos 2026-03-27 10:04:58 -07:00
parent cbb220f08d
commit 225afc3108
14 changed files with 484 additions and 0 deletions

View file

@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.wallet.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Sulkta Coop.
~
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<manifest />

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
/**
* Entry point for the Cardano wallet feature.
* Provides navigation to payment flows and wallet management.
*/
interface WalletEntryPoint : FeatureEntryPoint {
/**
* Builder for creating wallet flow nodes.
*/
interface Builder {
fun setRoomId(roomId: RoomId): Builder
fun setRecipientUserId(userId: UserId?): Builder
fun setRecipientAddress(address: String?): Builder
fun setAmount(amount: String?): Builder
fun build(): Node
}
/**
* Creates a builder for the payment flow.
*/
fun paymentFlowBuilder(
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
): Builder
/**
* Callback for wallet flow events.
*/
interface Callback : Plugin {
fun onPaymentSent(txHash: String)
fun onPaymentCancelled()
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.api
/**
* Represents the current state of the Cardano wallet.
*/
data class WalletState(
val hasWallet: Boolean,
val address: String?,
val balanceLovelace: Long?,
val balanceAda: String?,
val isLoading: Boolean,
val error: String?,
) {
companion object {
val Initial = WalletState(
hasWallet = false,
address = null,
balanceLovelace = null,
balanceAda = null,
isLoading = true,
error = null,
)
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import extension.setupDependencyInjection
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.features.wallet.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
api(projects.features.wallet.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.impl)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.uiStrings)
// Cardano - using Koios backend (no API key required)
implementation("com.bloxbean.cardano:cardano-client-lib:0.7.1")
implementation("com.bloxbean.cardano:cardano-client-backend-koios:0.7.1")
implementation("com.bloxbean.cardano:cardano-client-crypto:0.7.1")
// Biometric
implementation(libs.androidx.biometric)
// JSON
implementation(libs.serialization.json)
// Coroutines
implementation(libs.coroutines.core)
// Testing
testImplementation(projects.features.wallet.test)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.coroutines.test)
}

10
features/wallet/impl/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,10 @@
# Cardano client library uses reflection for CBOR serialization
-keep class com.bloxbean.cardano.** { *; }
-keepclassmembers class * {
@com.fasterxml.jackson.annotation.* *;
}
# Keep the Cardano model classes
-keep class com.bloxbean.cardano.client.api.model.** { *; }
-keep class com.bloxbean.cardano.client.backend.model.** { *; }
-keep class com.bloxbean.cardano.client.transaction.spec.** { *; }

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Sulkta Coop.
~
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<manifest />

View file

@ -0,0 +1,69 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.wallet.api.WalletEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultWalletEntryPoint @Inject constructor() : WalletEntryPoint {
class Builder(
private val parentNode: Node,
private val buildContext: BuildContext,
private val callback: WalletEntryPoint.Callback,
) : WalletEntryPoint.Builder {
private var roomId: RoomId? = null
private var recipientUserId: UserId? = null
private var recipientAddress: String? = null
private var amount: String? = null
override fun setRoomId(roomId: RoomId): Builder {
this.roomId = roomId
return this
}
override fun setRecipientUserId(userId: UserId?): Builder {
this.recipientUserId = userId
return this
}
override fun setRecipientAddress(address: String?): Builder {
this.recipientAddress = address
return this
}
override fun setAmount(amount: String?): Builder {
this.amount = amount
return this
}
override fun build(): Node {
val inputs = PaymentFlowNode.Inputs(
roomId = requireNotNull(roomId) { "roomId must be set" },
recipientUserId = recipientUserId,
recipientAddress = recipientAddress,
amount = amount,
)
return parentNode.createNode<PaymentFlowNode>(buildContext, listOf(inputs, callback))
}
}
override fun paymentFlowBuilder(
parentNode: Node,
buildContext: BuildContext,
callback: WalletEntryPoint.Callback,
): WalletEntryPoint.Builder {
return Builder(parentNode, buildContext, callback)
}
}

View file

@ -0,0 +1,108 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.wallet.api.WalletEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@AssistedInject
class PaymentFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<PaymentFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Confirm,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
@Parcelize
data class Inputs(
val roomId: RoomId,
val recipientUserId: UserId?,
val recipientAddress: String?,
val amount: String?,
) : NodeInputs, Parcelable
private val callback: WalletEntryPoint.Callback = callback()
private val inputs: Inputs = plugins.filterIsInstance<Inputs>().first()
sealed interface NavTarget : Parcelable {
@Parcelize
data object Confirm : NavTarget
@Parcelize
data object Success : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Confirm -> {
// TODO: Implement PaymentConfirmNode
createNode<PlaceholderNode>(buildContext, listOf(PlaceholderNode.Inputs("Payment Confirm")))
}
NavTarget.Success -> {
// TODO: Implement PaymentSuccessNode
createNode<PlaceholderNode>(buildContext, listOf(PlaceholderNode.Inputs("Payment Success")))
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}
/**
* Placeholder node for development. Will be replaced with actual implementations.
*/
@ContributesNode(SessionScope::class)
@AssistedInject
class PlaceholderNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins) {
@Parcelize
data class Inputs(val label: String) : NodeInputs, Parcelable
private val inputs: Inputs = plugins.filterIsInstance<Inputs>().first()
@Composable
override fun View(modifier: Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(text = "Placeholder: ${inputs.label}")
}
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.ObjectFactory
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import kotlinx.serialization.json.Json
/**
* DI module providing wallet-related dependencies.
*/
@ContributesTo(AppScope::class)
@ObjectFactory
interface WalletModule {
companion object {
@Provides
@SingleIn(AppScope::class)
fun provideWalletJson(): Json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
}
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.wallet.api.WalletState
import org.junit.Test
class WalletStateTest {
@Test
fun `initial state has correct defaults`() {
val state = WalletState.Initial
assertThat(state.hasWallet).isFalse()
assertThat(state.address).isNull()
assertThat(state.balanceLovelace).isNull()
assertThat(state.balanceAda).isNull()
assertThat(state.isLoading).isTrue()
assertThat(state.error).isNull()
}
@Test
fun `state can be updated`() {
val state = WalletState(
hasWallet = true,
address = "addr1test",
balanceLovelace = 10_000_000L,
balanceAda = "10",
isLoading = false,
error = null,
)
assertThat(state.hasWallet).isTrue()
assertThat(state.address).isEqualTo("addr1test")
assertThat(state.balanceLovelace).isEqualTo(10_000_000L)
assertThat(state.balanceAda).isEqualTo("10")
assertThat(state.isLoading).isFalse()
}
}

View file

@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.wallet.test"
}
dependencies {
api(projects.features.wallet.api)
implementation(projects.tests.testutils)
implementation(libs.coroutines.core)
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Sulkta Coop.
~
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<manifest />

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.features.wallet.api.WalletEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeWalletEntryPoint : WalletEntryPoint {
class Builder : WalletEntryPoint.Builder {
override fun setRoomId(roomId: RoomId): Builder = this
override fun setRecipientUserId(userId: UserId?): Builder = this
override fun setRecipientAddress(address: String?): Builder = this
override fun setAmount(amount: String?): Builder = this
override fun build(): Node = lambdaError()
}
override fun paymentFlowBuilder(
parentNode: Node,
buildContext: BuildContext,
callback: WalletEntryPoint.Callback,
): WalletEntryPoint.Builder {
return Builder()
}
}