From 225afc31082a91b84577d8794f388fa3a82404ac Mon Sep 17 00:00:00 2001 From: Kayos Date: Fri, 27 Mar 2026 10:04:58 -0700 Subject: [PATCH] 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. --- features/wallet/api/build.gradle.kts | 18 +++ .../wallet/api/src/main/AndroidManifest.xml | 7 ++ .../features/wallet/api/WalletEntryPoint.kt | 48 ++++++++ .../features/wallet/api/WalletState.kt | 30 +++++ features/wallet/impl/build.gradle.kts | 55 +++++++++ features/wallet/impl/proguard-rules.pro | 10 ++ .../wallet/impl/src/main/AndroidManifest.xml | 7 ++ .../wallet/impl/DefaultWalletEntryPoint.kt | 69 +++++++++++ .../features/wallet/impl/PaymentFlowNode.kt | 108 ++++++++++++++++++ .../features/wallet/impl/di/WalletModule.kt | 31 +++++ .../features/wallet/impl/WalletStateTest.kt | 43 +++++++ features/wallet/test/build.gradle.kts | 19 +++ .../wallet/test/src/main/AndroidManifest.xml | 7 ++ .../wallet/test/FakeWalletEntryPoint.kt | 32 ++++++ 14 files changed, 484 insertions(+) create mode 100644 features/wallet/api/build.gradle.kts create mode 100644 features/wallet/api/src/main/AndroidManifest.xml create mode 100644 features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletEntryPoint.kt create mode 100644 features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletState.kt create mode 100644 features/wallet/impl/build.gradle.kts create mode 100644 features/wallet/impl/proguard-rules.pro create mode 100644 features/wallet/impl/src/main/AndroidManifest.xml create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/DefaultWalletEntryPoint.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/PaymentFlowNode.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/di/WalletModule.kt create mode 100644 features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/WalletStateTest.kt create mode 100644 features/wallet/test/build.gradle.kts create mode 100644 features/wallet/test/src/main/AndroidManifest.xml create mode 100644 features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeWalletEntryPoint.kt diff --git a/features/wallet/api/build.gradle.kts b/features/wallet/api/build.gradle.kts new file mode 100644 index 0000000000..351d8373c2 --- /dev/null +++ b/features/wallet/api/build.gradle.kts @@ -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) +} diff --git a/features/wallet/api/src/main/AndroidManifest.xml b/features/wallet/api/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..0baf68a8a8 --- /dev/null +++ b/features/wallet/api/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletEntryPoint.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletEntryPoint.kt new file mode 100644 index 0000000000..c8d5595dcc --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletEntryPoint.kt @@ -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() + } +} diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletState.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletState.kt new file mode 100644 index 0000000000..42e4f134a9 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletState.kt @@ -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, + ) + } +} diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts new file mode 100644 index 0000000000..5ea24bb2ff --- /dev/null +++ b/features/wallet/impl/build.gradle.kts @@ -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) +} diff --git a/features/wallet/impl/proguard-rules.pro b/features/wallet/impl/proguard-rules.pro new file mode 100644 index 0000000000..a520813972 --- /dev/null +++ b/features/wallet/impl/proguard-rules.pro @@ -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.** { *; } diff --git a/features/wallet/impl/src/main/AndroidManifest.xml b/features/wallet/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..0baf68a8a8 --- /dev/null +++ b/features/wallet/impl/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/DefaultWalletEntryPoint.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/DefaultWalletEntryPoint.kt new file mode 100644 index 0000000000..07a8726c23 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/DefaultWalletEntryPoint.kt @@ -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(buildContext, listOf(inputs, callback)) + } + } + + override fun paymentFlowBuilder( + parentNode: Node, + buildContext: BuildContext, + callback: WalletEntryPoint.Callback, + ): WalletEntryPoint.Builder { + return Builder(parentNode, buildContext, callback) + } +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/PaymentFlowNode.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/PaymentFlowNode.kt new file mode 100644 index 0000000000..29a39149c3 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/PaymentFlowNode.kt @@ -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, +) : BaseFlowNode( + 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().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(buildContext, listOf(PlaceholderNode.Inputs("Payment Confirm"))) + } + NavTarget.Success -> { + // TODO: Implement PaymentSuccessNode + createNode(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, +) : Node(buildContext, plugins = plugins) { + @Parcelize + data class Inputs(val label: String) : NodeInputs, Parcelable + + private val inputs: Inputs = plugins.filterIsInstance().first() + + @Composable + override fun View(modifier: Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text(text = "Placeholder: ${inputs.label}") + } + } +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/di/WalletModule.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/di/WalletModule.kt new file mode 100644 index 0000000000..66663b34e0 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/di/WalletModule.kt @@ -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 + } + } +} diff --git a/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/WalletStateTest.kt b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/WalletStateTest.kt new file mode 100644 index 0000000000..b533ef333b --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/WalletStateTest.kt @@ -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() + } +} diff --git a/features/wallet/test/build.gradle.kts b/features/wallet/test/build.gradle.kts new file mode 100644 index 0000000000..dbaef4b5b9 --- /dev/null +++ b/features/wallet/test/build.gradle.kts @@ -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) +} diff --git a/features/wallet/test/src/main/AndroidManifest.xml b/features/wallet/test/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..0baf68a8a8 --- /dev/null +++ b/features/wallet/test/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + diff --git a/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeWalletEntryPoint.kt b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeWalletEntryPoint.kt new file mode 100644 index 0000000000..1af86ff25f --- /dev/null +++ b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeWalletEntryPoint.kt @@ -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() + } +}