diff --git a/backend/mpc-system/services/service-party-android/.gitignore b/backend/mpc-system/services/service-party-android/.gitignore
new file mode 100644
index 00000000..08067624
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/.gitignore
@@ -0,0 +1,96 @@
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+release/
+
+# Gradle files
+.gradle/
+build/
+app/build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
+*.iml
+.idea/
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/gradle.xml
+.idea/assetWizardSettings.xml
+.idea/dictionaries
+.idea/libraries
+.idea/caches
+.idea/modules.xml
+.idea/misc.xml
+.idea/vcs.xml
+
+# Keystore files (DO NOT COMMIT production keystores)
+*.jks
+*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+.cxx/
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+
+# Kotlin
+.kotlin/
+
+# OS-specific files
+.DS_Store
+Thumbs.db
+*.swp
+*~
+
+# Signing configs - don't commit
+signing.properties
+keystore.properties
diff --git a/backend/mpc-system/services/service-party-android/README.md b/backend/mpc-system/services/service-party-android/README.md
new file mode 100644
index 00000000..6ec234cd
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/README.md
@@ -0,0 +1,101 @@
+# TSS Party Android
+
+Android 版本的 TSS (Threshold Signature Scheme) Party 应用,用于多方共管钱包的密钥生成和签名。
+
+## 项目结构
+
+```
+service-party-android/
+├── app/ # Android 应用模块
+│ ├── src/main/
+│ │ ├── java/com/durian/tssparty/
+│ │ │ ├── data/ # 数据层
+│ │ │ │ ├── local/ # 本地存储 (Room, TSS Bridge)
+│ │ │ │ ├── remote/ # 远程通信 (gRPC)
+│ │ │ │ └── repository/ # 数据仓库
+│ │ │ ├── domain/model/ # 领域模型
+│ │ │ ├── presentation/ # UI 层
+│ │ │ │ ├── screens/ # Compose 屏幕
+│ │ │ │ └── viewmodel/ # ViewModels
+│ │ │ ├── di/ # Hilt 依赖注入
+│ │ │ ├── ui/theme/ # Material Theme
+│ │ │ └── util/ # 工具类
+│ │ ├── proto/ # gRPC Proto 文件
+│ │ └── res/ # Android 资源
+│ └── libs/ # TSS 原生库 (.aar)
+├── tsslib/ # Go TSS 库源码
+│ ├── tsslib.go # gomobile 绑定
+│ ├── go.mod
+│ ├── build.sh # Linux/macOS 构建脚本
+│ └── build.bat # Windows 构建脚本
+└── gradle/ # Gradle Wrapper
+```
+
+## 技术栈
+
+- **UI**: Jetpack Compose + Material 3
+- **架构**: MVVM + Repository Pattern
+- **依赖注入**: Hilt
+- **数据库**: Room
+- **网络**: gRPC (protobuf-lite)
+- **TSS 核心**: Go + gomobile (BnB Chain tss-lib v2)
+
+## 构建步骤
+
+### 1. 构建 TSS 原生库 (可选,需要 Go 环境)
+
+```bash
+# 安装 gomobile
+go install golang.org/x/mobile/cmd/gomobile@latest
+gomobile init
+
+# 构建 Android AAR
+cd tsslib
+./build.sh # Linux/macOS
+# 或
+build.bat # Windows
+```
+
+这将在 `app/libs/` 生成 `tsslib.aar`。
+
+> **注意**: 当前版本使用 Kotlin stub 实现,无需编译 Go 库即可构建 APK。
+> 实际运行需要真正的 `tsslib.aar`。
+
+### 2. 构建 APK
+
+```bash
+# Debug 版本
+./gradlew assembleDebug
+
+# Release 版本 (需要签名配置)
+./gradlew assembleRelease
+```
+
+APK 输出路径: `app/build/outputs/apk/debug/app-debug.apk`
+
+## 功能
+
+1. **加入 Keygen 会话** - 扫描/输入邀请码,参与多方密钥生成
+2. **查看钱包** - 显示已创建的共管钱包列表
+3. **签名交易** - 使用密钥份额参与多方签名
+4. **设置** - 配置 Message Router 服务器地址
+
+## 配置
+
+默认服务器配置:
+- Message Router: `localhost:50051`
+- Kava RPC: `https://evm.kava.io`
+
+## 与 Electron 版本的对应关系
+
+| Electron 版本 | Android 版本 |
+|---------------|--------------|
+| `electron/main.ts` | `TssNativeBridge.kt` + `GrpcClient.kt` |
+| `electron/preload.ts` | `TssRepository.kt` |
+| `src/pages/*.tsx` | `presentation/screens/*.kt` |
+| `tss-party/` (Go 子进程) | `tsslib/` (gomobile .aar) |
+| sql.js | Room Database |
+
+## 许可证
+
+MIT
diff --git a/backend/mpc-system/services/service-party-android/app/build.gradle.kts b/backend/mpc-system/services/service-party-android/app/build.gradle.kts
new file mode 100644
index 00000000..1eabf5dd
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/build.gradle.kts
@@ -0,0 +1,185 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.google.dagger.hilt.android")
+ id("com.google.protobuf")
+ kotlin("kapt")
+}
+
+android {
+ namespace = "com.durian.tssparty"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.durian.tssparty"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+
+ // NDK configuration for TSS native library
+ ndk {
+ abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
+ }
+ }
+
+ signingConfigs {
+ create("release") {
+ // Use debug keystore for now - replace with production keystore for real release
+ storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
+ storePassword = "android"
+ keyAlias = "androiddebugkey"
+ keyPassword = "android"
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false // Disable minification for easier debugging
+ isShrinkResources = false
+ signingConfig = signingConfigs.getByName("release")
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ debug {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.6"
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+
+ sourceSets {
+ getByName("main") {
+ // Include the compiled TSS .aar library
+ jniLibs.srcDirs("libs")
+ }
+ }
+}
+
+// Protobuf configuration for gRPC
+protobuf {
+ protoc {
+ artifact = "com.google.protobuf:protoc:3.25.1"
+ }
+ plugins {
+ create("grpc") {
+ artifact = "io.grpc:protoc-gen-grpc-java:1.60.0"
+ }
+ }
+ generateProtoTasks {
+ all().forEach { task ->
+ task.builtins {
+ create("java") {
+ option("lite")
+ }
+ }
+ task.plugins {
+ create("grpc") {
+ option("lite")
+ }
+ }
+ }
+ }
+}
+
+dependencies {
+ // TSS Library (gomobile generated)
+ implementation(files("libs/tsslib.aar"))
+
+ // Core Android
+ implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
+ implementation("androidx.activity:activity-compose:1.8.2")
+
+ // Compose
+ implementation(platform("androidx.compose:compose-bom:2023.10.01"))
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ implementation("androidx.navigation:navigation-compose:2.7.6")
+
+ // Hilt DI
+ implementation("com.google.dagger:hilt-android:2.48.1")
+ kapt("com.google.dagger:hilt-android-compiler:2.48.1")
+ implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
+
+ // Room Database
+ implementation("androidx.room:room-runtime:2.6.1")
+ implementation("androidx.room:room-ktx:2.6.1")
+ kapt("androidx.room:room-compiler:2.6.1")
+
+ // gRPC
+ implementation("io.grpc:grpc-okhttp:1.60.0")
+ implementation("io.grpc:grpc-protobuf-lite:1.60.0")
+ implementation("io.grpc:grpc-stub:1.60.0")
+ implementation("io.grpc:grpc-kotlin-stub:1.4.1")
+ implementation("com.google.protobuf:protobuf-kotlin-lite:3.25.1")
+
+ // Networking
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
+ implementation("com.squareup.retrofit2:retrofit:2.9.0")
+ implementation("com.squareup.retrofit2:converter-gson:2.9.0")
+
+ // Coroutines
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
+
+ // JSON
+ implementation("com.google.code.gson:gson:2.10.1")
+
+ // QR Code
+ implementation("com.google.zxing:core:3.5.2")
+ implementation("com.journeyapps:zxing-android-embedded:4.3.0")
+
+ // Crypto
+ implementation("org.bouncycastle:bcprov-jdk18on:1.77")
+
+ // DataStore for preferences
+ implementation("androidx.datastore:datastore-preferences:1.0.0")
+
+ // Testing
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
+
+kapt {
+ correctErrorTypes = true
+}
diff --git a/backend/mpc-system/services/service-party-android/app/libs/tsslib-sources.jar b/backend/mpc-system/services/service-party-android/app/libs/tsslib-sources.jar
new file mode 100644
index 00000000..b8480d73
Binary files /dev/null and b/backend/mpc-system/services/service-party-android/app/libs/tsslib-sources.jar differ
diff --git a/backend/mpc-system/services/service-party-android/app/proguard-rules.pro b/backend/mpc-system/services/service-party-android/app/proguard-rules.pro
new file mode 100644
index 00000000..8f8b2f00
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+
+# Keep gRPC classes
+-keep class io.grpc.** { *; }
+-keep class com.google.protobuf.** { *; }
+-keep class com.durian.tssparty.grpc.** { *; }
+
+# Keep tsslib (gomobile generated)
+-keep class tsslib.** { *; }
+
+# Keep Hilt generated classes
+-keep class dagger.hilt.** { *; }
+-keep class javax.inject.** { *; }
+
+# Keep Room entities
+-keep class com.durian.tssparty.data.local.** { *; }
+
+# Gson
+-keepattributes Signature
+-keepattributes *Annotation*
+-keep class com.durian.tssparty.domain.model.** { *; }
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/AndroidManifest.xml b/backend/mpc-system/services/service-party-android/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a441ceec
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt
new file mode 100644
index 00000000..5b09ddb0
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt
@@ -0,0 +1,396 @@
+package com.durian.tssparty
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import com.durian.tssparty.domain.model.AppReadyState
+import com.durian.tssparty.presentation.components.BottomNavItem
+import com.durian.tssparty.presentation.components.TssBottomNavigation
+import com.durian.tssparty.presentation.screens.*
+import com.durian.tssparty.presentation.viewmodel.MainViewModel
+import com.durian.tssparty.presentation.viewmodel.ConnectionTestResult as ViewModelConnectionTestResult
+import com.durian.tssparty.ui.theme.TssPartyTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ TssPartyTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ TssPartyApp(
+ onCopyToClipboard = { text ->
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText("邀请码", text)
+ clipboard.setPrimaryClip(clip)
+ Toast.makeText(this, "邀请码已复制", Toast.LENGTH_SHORT).show()
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TssPartyApp(
+ viewModel: MainViewModel = hiltViewModel(),
+ onCopyToClipboard: (String) -> Unit = {}
+) {
+ val navController = rememberNavController()
+ val appState by viewModel.appState.collectAsState()
+ val uiState by viewModel.uiState.collectAsState()
+ val shares by viewModel.shares.collectAsState()
+ val sessionStatus by viewModel.sessionStatus.collectAsState()
+ val settings by viewModel.settings.collectAsState()
+ val createdInviteCode by viewModel.createdInviteCode.collectAsState()
+ val balances by viewModel.balances.collectAsState()
+ val currentSessionId by viewModel.currentSessionId.collectAsState()
+ val sessionParticipants by viewModel.sessionParticipants.collectAsState()
+ val currentRound by viewModel.currentRound.collectAsState()
+ val publicKey by viewModel.publicKey.collectAsState()
+
+ // Transfer state
+ val preparedTx by viewModel.preparedTx.collectAsState()
+ val signSessionId by viewModel.signSessionId.collectAsState()
+ val signInviteCode by viewModel.signInviteCode.collectAsState()
+ val signParticipants by viewModel.signParticipants.collectAsState()
+ val signCurrentRound by viewModel.signCurrentRound.collectAsState()
+ val signature by viewModel.signature.collectAsState()
+ val txHash by viewModel.txHash.collectAsState()
+
+ // Join keygen state
+ val joinSessionInfo by viewModel.joinSessionInfo.collectAsState()
+ val joinKeygenParticipants by viewModel.joinKeygenParticipants.collectAsState()
+ val joinKeygenRound by viewModel.joinKeygenRound.collectAsState()
+ val joinKeygenPublicKey by viewModel.joinKeygenPublicKey.collectAsState()
+
+ // CoSign state
+ val coSignSessionInfo by viewModel.coSignSessionInfo.collectAsState()
+ val coSignParticipants by viewModel.coSignParticipants.collectAsState()
+ val coSignRound by viewModel.coSignRound.collectAsState()
+ val coSignSignature by viewModel.coSignSignature.collectAsState()
+
+ // Settings test connection results
+ val messageRouterTestResult by viewModel.messageRouterTestResult.collectAsState()
+ val accountServiceTestResult by viewModel.accountServiceTestResult.collectAsState()
+ val kavaApiTestResult by viewModel.kavaApiTestResult.collectAsState()
+
+ // Current transfer wallet
+ var transferWalletId by remember { mutableStateOf(null) }
+
+ // Track if startup is complete
+ var startupComplete by remember { mutableStateOf(false) }
+
+ // Handle success messages
+ LaunchedEffect(uiState.successMessage) {
+ if (uiState.successMessage != null) {
+ // Navigate back to wallets on success
+ navController.navigate(BottomNavItem.Wallets.route) {
+ popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
+ }
+ viewModel.clearSuccess()
+ viewModel.clearCreatedInviteCode()
+ }
+ }
+
+ // Show startup check screen if not complete
+ if (!startupComplete) {
+ StartupCheckScreen(
+ appState = appState,
+ onEnterApp = { startupComplete = true },
+ onRetry = { viewModel.checkAllServices() }
+ )
+ return
+ }
+
+ // Main app with bottom navigation
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = navBackStackEntry?.destination?.route ?: BottomNavItem.Wallets.route
+
+ Scaffold(
+ bottomBar = {
+ TssBottomNavigation(
+ currentRoute = currentRoute,
+ onNavigate = { item ->
+ navController.navigate(item.route) {
+ // Pop up to the start destination to avoid building up a large stack
+ popUpTo(BottomNavItem.Wallets.route) {
+ saveState = true
+ }
+ // Avoid multiple copies of the same destination
+ launchSingleTop = true
+ // Restore state when reselecting a previously selected item
+ restoreState = true
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ NavHost(
+ navController = navController,
+ startDestination = BottomNavItem.Wallets.route,
+ modifier = Modifier.padding(paddingValues)
+ ) {
+ // Tab 1: My Wallets (我的钱包)
+ composable(BottomNavItem.Wallets.route) {
+ // Fetch balances when entering wallets screen
+ LaunchedEffect(shares) {
+ viewModel.fetchAllBalances()
+ }
+
+ WalletsScreen(
+ shares = shares,
+ isConnected = uiState.isConnected,
+ balances = balances,
+ onDeleteShare = { viewModel.deleteShare(it) },
+ onRefreshBalance = { address -> viewModel.fetchBalance(address) },
+ onTransfer = { shareId, toAddress, amount, password ->
+ transferWalletId = shareId
+ viewModel.prepareTransfer(shareId, toAddress, amount)
+ navController.navigate("transfer/$shareId")
+ },
+ onCreateWallet = {
+ navController.navigate(BottomNavItem.Create.route)
+ }
+ )
+ }
+
+ // Transfer Screen
+ composable("transfer/{shareId}") { backStackEntry ->
+ val shareId = backStackEntry.arguments?.getString("shareId")?.toLongOrNull()
+ val wallet = shareId?.let { viewModel.getWalletById(it) }
+
+ if (wallet != null) {
+ TransferScreen(
+ wallet = wallet,
+ balance = balances[wallet.address],
+ sessionStatus = sessionStatus,
+ participants = signParticipants,
+ currentRound = signCurrentRound,
+ totalRounds = 9,
+ preparedTx = preparedTx,
+ signSessionId = signSessionId,
+ inviteCode = signInviteCode,
+ signature = signature,
+ txHash = txHash,
+ isLoading = uiState.isLoading,
+ error = uiState.error,
+ networkType = settings.networkType,
+ onPrepareTransaction = { toAddress, amount ->
+ viewModel.prepareTransfer(shareId, toAddress, amount)
+ },
+ onConfirmTransaction = { password ->
+ viewModel.initiateSignSession(shareId, password)
+ },
+ onCopyInviteCode = {
+ signInviteCode?.let { onCopyToClipboard(it) }
+ },
+ onBroadcastTransaction = {
+ viewModel.broadcastTransaction()
+ },
+ onCancel = {
+ viewModel.resetTransferState()
+ viewModel.clearError()
+ navController.popBackStack()
+ },
+ onBackToWallets = {
+ viewModel.resetTransferState()
+ navController.navigate(BottomNavItem.Wallets.route) {
+ popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
+ }
+ }
+ )
+ }
+ }
+
+ // Tab 2: Create Wallet (创建钱包)
+ composable(BottomNavItem.Create.route) {
+ CreateWalletScreen(
+ isLoading = uiState.isLoading,
+ error = uiState.error,
+ inviteCode = createdInviteCode,
+ sessionId = currentSessionId,
+ sessionStatus = sessionStatus,
+ participants = sessionParticipants,
+ currentRound = currentRound,
+ totalRounds = 9,
+ publicKey = publicKey,
+ onCreateSession = { name, t, n, participantName ->
+ viewModel.createKeygenSession(name, t, n, participantName)
+ },
+ onCopyInviteCode = {
+ createdInviteCode?.let { onCopyToClipboard(it) }
+ },
+ onEnterSession = {
+ viewModel.enterSession()
+ },
+ onCancel = {
+ viewModel.cancelSession()
+ viewModel.clearError()
+ viewModel.resetSessionState()
+ },
+ onBackToHome = {
+ viewModel.resetSessionState()
+ navController.navigate(BottomNavItem.Wallets.route) {
+ popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
+ }
+ }
+ )
+ }
+
+ // Tab 3: Join Keygen (加入创建)
+ composable(BottomNavItem.JoinKeygen.route) {
+ // Convert JoinKeygenSessionInfo to JoinSessionInfo for the screen
+ val screenSessionInfo = joinSessionInfo?.let {
+ JoinSessionInfo(
+ sessionId = it.sessionId,
+ walletName = it.walletName,
+ thresholdT = it.thresholdT,
+ thresholdN = it.thresholdN,
+ initiator = it.initiator,
+ currentParticipants = it.currentParticipants,
+ totalParticipants = it.totalParticipants
+ )
+ }
+
+ JoinKeygenScreen(
+ sessionStatus = sessionStatus,
+ isLoading = uiState.isLoading,
+ error = uiState.error,
+ sessionInfo = screenSessionInfo,
+ participants = joinKeygenParticipants,
+ currentRound = joinKeygenRound,
+ totalRounds = 9,
+ publicKey = joinKeygenPublicKey,
+ onValidateInviteCode = { inviteCode ->
+ viewModel.validateInviteCode(inviteCode)
+ },
+ onJoinKeygen = { inviteCode, password ->
+ viewModel.joinKeygen(inviteCode, password)
+ },
+ onCancel = {
+ viewModel.cancelSession()
+ viewModel.clearError()
+ viewModel.resetJoinKeygenState()
+ },
+ onBackToHome = {
+ viewModel.resetJoinKeygenState()
+ navController.navigate(BottomNavItem.Wallets.route) {
+ popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
+ }
+ }
+ )
+ }
+
+ // Tab 4: Co-Sign (参与签名)
+ composable(BottomNavItem.CoSign.route) {
+ // Convert CoSignSessionInfo to SignSessionInfo for the screen
+ val screenSignSessionInfo = coSignSessionInfo?.let {
+ SignSessionInfo(
+ sessionId = it.sessionId,
+ keygenSessionId = it.keygenSessionId,
+ walletName = it.walletName,
+ messageHash = it.messageHash,
+ thresholdT = it.thresholdT,
+ thresholdN = it.thresholdN,
+ currentParticipants = it.currentParticipants
+ )
+ }
+
+ CoSignJoinScreen(
+ shares = shares,
+ sessionStatus = sessionStatus,
+ isLoading = uiState.isLoading,
+ error = uiState.error,
+ signSessionInfo = screenSignSessionInfo,
+ participants = coSignParticipants,
+ currentRound = coSignRound,
+ totalRounds = 9,
+ signature = coSignSignature,
+ onValidateInviteCode = { inviteCode ->
+ viewModel.validateSignInviteCode(inviteCode)
+ },
+ onJoinSign = { inviteCode, shareId, password ->
+ viewModel.joinSign(inviteCode, shareId, password)
+ },
+ onCancel = {
+ viewModel.cancelSession()
+ viewModel.clearError()
+ viewModel.resetCoSignState()
+ },
+ onBackToHome = {
+ viewModel.resetCoSignState()
+ navController.navigate(BottomNavItem.Wallets.route) {
+ popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
+ }
+ }
+ )
+ }
+
+ // Tab 5: Settings (设置)
+ composable(BottomNavItem.Settings.route) {
+ // Convert ViewModel ConnectionTestResult to Screen ConnectionTestResult
+ val screenMessageRouterStatus: ConnectionTestResult? = messageRouterTestResult?.let {
+ ConnectionTestResult(
+ success = it.success,
+ message = it.message,
+ latency = it.latency
+ )
+ }
+ val screenAccountServiceStatus: ConnectionTestResult? = accountServiceTestResult?.let {
+ ConnectionTestResult(
+ success = it.success,
+ message = it.message,
+ latency = it.latency
+ )
+ }
+ val screenKavaApiStatus: ConnectionTestResult? = kavaApiTestResult?.let {
+ ConnectionTestResult(
+ success = it.success,
+ message = it.message,
+ latency = it.latency
+ )
+ }
+
+ SettingsScreen(
+ settings = settings,
+ isConnected = uiState.isConnected,
+ messageRouterStatus = screenMessageRouterStatus,
+ accountServiceStatus = screenAccountServiceStatus,
+ kavaApiStatus = screenKavaApiStatus,
+ onSaveSettings = { newSettings ->
+ viewModel.updateSettings(newSettings)
+ },
+ onTestMessageRouter = { url ->
+ viewModel.testMessageRouter(url)
+ },
+ onTestAccountService = { url ->
+ viewModel.testAccountService(url)
+ },
+ onTestKavaApi = { url ->
+ viewModel.testKavaApi(url)
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/TssPartyApplication.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/TssPartyApplication.kt
new file mode 100644
index 00000000..142582d2
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/TssPartyApplication.kt
@@ -0,0 +1,7 @@
+package com.durian.tssparty
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class TssPartyApplication : Application()
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/Database.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/Database.kt
new file mode 100644
index 00000000..45f0331a
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/Database.kt
@@ -0,0 +1,79 @@
+package com.durian.tssparty.data.local
+
+import androidx.room.*
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Entity for storing TSS share records
+ */
+@Entity(tableName = "share_records")
+data class ShareRecordEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: Long = 0,
+
+ @ColumnInfo(name = "session_id")
+ val sessionId: String,
+
+ @ColumnInfo(name = "public_key")
+ val publicKey: String,
+
+ @ColumnInfo(name = "encrypted_share")
+ val encryptedShare: String,
+
+ @ColumnInfo(name = "threshold_t")
+ val thresholdT: Int,
+
+ @ColumnInfo(name = "threshold_n")
+ val thresholdN: Int,
+
+ @ColumnInfo(name = "party_index")
+ val partyIndex: Int,
+
+ @ColumnInfo(name = "address")
+ val address: String,
+
+ @ColumnInfo(name = "created_at")
+ val createdAt: Long = System.currentTimeMillis()
+)
+
+/**
+ * DAO for share records
+ */
+@Dao
+interface ShareRecordDao {
+ @Query("SELECT * FROM share_records ORDER BY created_at DESC")
+ fun getAllShares(): Flow>
+
+ @Query("SELECT * FROM share_records WHERE id = :id")
+ suspend fun getShareById(id: Long): ShareRecordEntity?
+
+ @Query("SELECT * FROM share_records WHERE session_id = :sessionId")
+ suspend fun getShareBySessionId(sessionId: String): ShareRecordEntity?
+
+ @Query("SELECT * FROM share_records WHERE address = :address")
+ suspend fun getShareByAddress(address: String): ShareRecordEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertShare(share: ShareRecordEntity): Long
+
+ @Delete
+ suspend fun deleteShare(share: ShareRecordEntity)
+
+ @Query("DELETE FROM share_records WHERE id = :id")
+ suspend fun deleteShareById(id: Long)
+
+ @Query("SELECT COUNT(*) FROM share_records")
+ suspend fun getShareCount(): Int
+}
+
+/**
+ * Room database
+ */
+@Database(
+ entities = [ShareRecordEntity::class],
+ version = 1,
+ exportSchema = false
+)
+abstract class TssDatabase : RoomDatabase() {
+ abstract fun shareRecordDao(): ShareRecordDao
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/TssNativeBridge.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/TssNativeBridge.kt
new file mode 100644
index 00000000..b37ce1b2
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/TssNativeBridge.kt
@@ -0,0 +1,172 @@
+package com.durian.tssparty.data.local
+
+import com.durian.tssparty.domain.model.KeygenResult
+import com.durian.tssparty.domain.model.Participant
+import com.durian.tssparty.domain.model.SignResult
+import com.durian.tssparty.domain.model.TssOutgoingMessage
+import com.google.gson.Gson
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.withContext
+import tsslib.MessageCallback
+import tsslib.Tsslib
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Bridge between Kotlin and Go TSS library via gomobile bindings
+ */
+@Singleton
+class TssNativeBridge @Inject constructor(
+ private val gson: Gson
+) {
+ private val _outgoingMessages = Channel(Channel.BUFFERED)
+ val outgoingMessages: Flow = _outgoingMessages.receiveAsFlow()
+
+ private val _progress = Channel>(Channel.BUFFERED)
+ val progress: Flow> = _progress.receiveAsFlow()
+
+ private val _errors = Channel(Channel.BUFFERED)
+ val errors: Flow = _errors.receiveAsFlow()
+
+ private val _logs = Channel(Channel.BUFFERED)
+ val logs: Flow = _logs.receiveAsFlow()
+
+ private val callback = object : MessageCallback {
+ override fun onOutgoingMessage(messageJSON: String) {
+ try {
+ val message = gson.fromJson(messageJSON, TssOutgoingMessage::class.java)
+ _outgoingMessages.trySend(message)
+ } catch (e: Exception) {
+ _errors.trySend("Failed to parse outgoing message: ${e.message}")
+ }
+ }
+
+ override fun onProgress(round: Long, totalRounds: Long) {
+ _progress.trySend(Pair(round.toInt(), totalRounds.toInt()))
+ }
+
+ override fun onError(errorMessage: String) {
+ _errors.trySend(errorMessage)
+ }
+
+ override fun onLog(message: String) {
+ _logs.trySend(message)
+ }
+ }
+
+ /**
+ * Start a keygen session
+ */
+ suspend fun startKeygen(
+ sessionId: String,
+ partyId: String,
+ partyIndex: Int,
+ thresholdT: Int,
+ thresholdN: Int,
+ participants: List,
+ password: String
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ val participantsJson = gson.toJson(participants)
+ Tsslib.startKeygen(
+ sessionId,
+ partyId,
+ partyIndex.toLong(),
+ thresholdT.toLong(),
+ thresholdN.toLong(),
+ participantsJson,
+ password,
+ callback
+ )
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Start a sign session
+ */
+ suspend fun startSign(
+ sessionId: String,
+ partyId: String,
+ partyIndex: Int,
+ thresholdT: Int,
+ thresholdN: Int,
+ participants: List,
+ messageHash: String,
+ shareData: String,
+ password: String
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ val participantsJson = gson.toJson(participants)
+ Tsslib.startSign(
+ sessionId,
+ partyId,
+ partyIndex.toLong(),
+ thresholdT.toLong(),
+ thresholdN.toLong(),
+ participantsJson,
+ messageHash,
+ shareData,
+ password,
+ callback
+ )
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Send incoming message from another party
+ */
+ suspend fun sendIncomingMessage(
+ fromPartyIndex: Int,
+ isBroadcast: Boolean,
+ payload: String
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ Tsslib.sendIncomingMessage(fromPartyIndex.toLong(), isBroadcast, payload)
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Wait for keygen result
+ */
+ suspend fun waitForKeygenResult(password: String): Result = withContext(Dispatchers.IO) {
+ try {
+ val resultJson = Tsslib.waitForKeygenResult(password)
+ val result = gson.fromJson(resultJson, KeygenResult::class.java)
+ Result.success(result)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Wait for sign result
+ */
+ suspend fun waitForSignResult(): Result = withContext(Dispatchers.IO) {
+ try {
+ val resultJson = Tsslib.waitForSignResult()
+ val result = gson.fromJson(resultJson, SignResult::class.java)
+ Result.success(result)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Cancel current session
+ */
+ fun cancelSession() {
+ Tsslib.cancelSession()
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt
new file mode 100644
index 00000000..ec560879
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt
@@ -0,0 +1,334 @@
+package com.durian.tssparty.data.remote
+
+import android.util.Base64
+import com.durian.tssparty.domain.model.Participant
+import com.durian.tssparty.grpc.*
+import io.grpc.ManagedChannel
+import io.grpc.ManagedChannelBuilder
+import io.grpc.stub.StreamObserver
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.withContext
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * gRPC client for Message Router service
+ */
+@Singleton
+class GrpcClient @Inject constructor() {
+
+ private var channel: ManagedChannel? = null
+ private var stub: MessageRouterGrpc.MessageRouterBlockingStub? = null
+ private var asyncStub: MessageRouterGrpc.MessageRouterStub? = null
+
+ /**
+ * Connect to the Message Router server
+ */
+ fun connect(host: String, port: Int) {
+ disconnect()
+
+ channel = ManagedChannelBuilder
+ .forAddress(host, port)
+ .usePlaintext() // TODO: Use TLS in production
+ .keepAliveTime(30, TimeUnit.SECONDS)
+ .keepAliveTimeout(10, TimeUnit.SECONDS)
+ .build()
+
+ stub = MessageRouterGrpc.newBlockingStub(channel)
+ asyncStub = MessageRouterGrpc.newStub(channel)
+ }
+
+ /**
+ * Disconnect from the server
+ */
+ fun disconnect() {
+ channel?.shutdown()
+ channel = null
+ stub = null
+ asyncStub = null
+ }
+
+ /**
+ * Register party with the router
+ */
+ suspend fun registerParty(
+ partyId: String,
+ partyRole: String = "temporary",
+ version: String = "1.0.0"
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ val request = RegisterPartyRequest.newBuilder()
+ .setPartyId(partyId)
+ .setPartyRole(partyRole)
+ .setVersion(version)
+ .build()
+
+ val response = stub?.registerParty(request)
+ Result.success(response?.success ?: false)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Join a session
+ */
+ suspend fun joinSession(
+ sessionId: String,
+ partyId: String,
+ joinToken: String
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ val deviceInfo = DeviceInfo.newBuilder()
+ .setDeviceType("mobile")
+ .setDeviceId(partyId)
+ .setPlatform("android")
+ .setAppVersion("1.0.0")
+ .build()
+
+ val request = JoinSessionRequest.newBuilder()
+ .setSessionId(sessionId)
+ .setPartyId(partyId)
+ .setJoinToken(joinToken)
+ .setDeviceInfo(deviceInfo)
+ .build()
+
+ val response = stub?.joinSession(request)
+ if (response?.success == true) {
+ val sessionInfo = response.sessionInfo
+ val participants = response.otherPartiesList.map { party ->
+ Participant(party.partyId, party.partyIndex)
+ }
+
+ Result.success(
+ JoinSessionData(
+ sessionId = sessionInfo.sessionId,
+ sessionType = sessionInfo.sessionType,
+ thresholdN = sessionInfo.thresholdN,
+ thresholdT = sessionInfo.thresholdT,
+ partyIndex = response.partyIndex,
+ participants = participants,
+ messageHash = if (sessionInfo.messageHash.isEmpty) null
+ else Base64.encodeToString(sessionInfo.messageHash.toByteArray(), Base64.NO_WRAP)
+ )
+ )
+ } else {
+ Result.failure(Exception("Failed to join session"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Mark party as ready
+ */
+ suspend fun markPartyReady(
+ sessionId: String,
+ partyId: String
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ val request = MarkPartyReadyRequest.newBuilder()
+ .setSessionId(sessionId)
+ .setPartyId(partyId)
+ .build()
+
+ val response = stub?.markPartyReady(request)
+ Result.success(response?.allReady ?: false)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Route a message to other parties
+ */
+ suspend fun routeMessage(
+ sessionId: String,
+ fromParty: String,
+ toParties: List,
+ roundNumber: Int,
+ messageType: String,
+ payload: ByteArray
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ val request = RouteMessageRequest.newBuilder()
+ .setSessionId(sessionId)
+ .setFromParty(fromParty)
+ .addAllToParties(toParties)
+ .setRoundNumber(roundNumber)
+ .setMessageType(messageType)
+ .setPayload(com.google.protobuf.ByteString.copyFrom(payload))
+ .build()
+
+ val response = stub?.routeMessage(request)
+ if (response?.success == true) {
+ Result.success(response.messageId)
+ } else {
+ Result.failure(Exception("Failed to route message"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Subscribe to messages for a party
+ */
+ fun subscribeMessages(sessionId: String, partyId: String): Flow = callbackFlow {
+ val request = SubscribeMessagesRequest.newBuilder()
+ .setSessionId(sessionId)
+ .setPartyId(partyId)
+ .build()
+
+ val observer = object : StreamObserver {
+ override fun onNext(message: MPCMessage) {
+ val incoming = IncomingMessage(
+ messageId = message.messageId,
+ fromParty = message.fromParty,
+ isBroadcast = message.isBroadcast,
+ roundNumber = message.roundNumber,
+ payload = Base64.encodeToString(message.payload.toByteArray(), Base64.NO_WRAP)
+ )
+ trySend(incoming)
+ }
+
+ override fun onError(t: Throwable) {
+ close(t)
+ }
+
+ override fun onCompleted() {
+ close()
+ }
+ }
+
+ asyncStub?.subscribeMessages(request, observer)
+
+ awaitClose { }
+ }
+
+ /**
+ * Subscribe to session events
+ */
+ fun subscribeSessionEvents(partyId: String): Flow = callbackFlow {
+ val request = SubscribeSessionEventsRequest.newBuilder()
+ .setPartyId(partyId)
+ .build()
+
+ val observer = object : StreamObserver {
+ override fun onNext(event: SessionEvent) {
+ val eventData = SessionEventData(
+ eventId = event.eventId,
+ eventType = event.eventType,
+ sessionId = event.sessionId,
+ thresholdN = event.thresholdN,
+ thresholdT = event.thresholdT,
+ selectedParties = event.selectedPartiesList,
+ joinTokens = event.joinTokensMap,
+ messageHash = if (event.messageHash.isEmpty) null
+ else Base64.encodeToString(event.messageHash.toByteArray(), Base64.NO_WRAP)
+ )
+ trySend(eventData)
+ }
+
+ override fun onError(t: Throwable) {
+ close(t)
+ }
+
+ override fun onCompleted() {
+ close()
+ }
+ }
+
+ asyncStub?.subscribeSessionEvents(request, observer)
+
+ awaitClose { }
+ }
+
+ /**
+ * Report completion
+ */
+ suspend fun reportCompletion(
+ sessionId: String,
+ partyId: String,
+ publicKey: ByteArray? = null,
+ signature: ByteArray? = null
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ val builder = ReportCompletionRequest.newBuilder()
+ .setSessionId(sessionId)
+ .setPartyId(partyId)
+
+ publicKey?.let {
+ builder.setPublicKey(com.google.protobuf.ByteString.copyFrom(it))
+ }
+ signature?.let {
+ builder.setSignature(com.google.protobuf.ByteString.copyFrom(it))
+ }
+
+ val response = stub?.reportCompletion(builder.build())
+ Result.success(response?.allCompleted ?: false)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Send heartbeat
+ */
+ suspend fun heartbeat(partyId: String): Result = withContext(Dispatchers.IO) {
+ try {
+ val request = HeartbeatRequest.newBuilder()
+ .setPartyId(partyId)
+ .setTimestamp(System.currentTimeMillis())
+ .build()
+
+ val response = stub?.heartbeat(request)
+ Result.success(response?.pendingMessages ?: 0)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
+
+/**
+ * Data class for join session response
+ */
+data class JoinSessionData(
+ val sessionId: String,
+ val sessionType: String,
+ val thresholdN: Int,
+ val thresholdT: Int,
+ val partyIndex: Int,
+ val participants: List,
+ val messageHash: String?
+)
+
+/**
+ * Data class for incoming MPC message
+ */
+data class IncomingMessage(
+ val messageId: String,
+ val fromParty: String,
+ val isBroadcast: Boolean,
+ val roundNumber: Int,
+ val payload: String // base64 encoded
+)
+
+/**
+ * Data class for session event
+ */
+data class SessionEventData(
+ val eventId: String,
+ val eventType: String,
+ val sessionId: String,
+ val thresholdN: Int,
+ val thresholdT: Int,
+ val selectedParties: List,
+ val joinTokens: Map,
+ val messageHash: String?
+)
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt
new file mode 100644
index 00000000..67f41c41
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt
@@ -0,0 +1,1221 @@
+package com.durian.tssparty.data.repository
+
+import android.util.Base64
+import com.durian.tssparty.data.local.ShareRecordDao
+import com.durian.tssparty.data.local.ShareRecordEntity
+import com.durian.tssparty.data.local.TssNativeBridge
+import com.durian.tssparty.data.remote.GrpcClient
+import com.durian.tssparty.data.remote.IncomingMessage
+import com.durian.tssparty.data.remote.JoinSessionData
+import com.durian.tssparty.data.remote.SessionEventData
+import com.durian.tssparty.domain.model.*
+import com.durian.tssparty.util.AddressUtils
+import com.durian.tssparty.util.TransactionUtils
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Repository for TSS operations
+ */
+@Singleton
+class TssRepository @Inject constructor(
+ private val grpcClient: GrpcClient,
+ private val tssNativeBridge: TssNativeBridge,
+ private val shareRecordDao: ShareRecordDao
+) {
+ private val _currentSession = MutableStateFlow(null)
+ val currentSession: StateFlow = _currentSession.asStateFlow()
+
+ private val _sessionStatus = MutableStateFlow(SessionStatus.WAITING)
+ val sessionStatus: StateFlow = _sessionStatus.asStateFlow()
+
+ private var partyId: String = UUID.randomUUID().toString()
+ private var messageCollectionJob: Job? = null
+
+ // Account service URL (configurable via settings)
+ private var accountServiceUrl: String = "https://rwaapi.szaiai.com"
+
+ // HTTP client for API calls
+ private val httpClient = okhttp3.OkHttpClient.Builder()
+ .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
+ .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
+ .build()
+
+ /**
+ * Update account service URL from settings
+ */
+ fun setAccountServiceUrl(url: String) {
+ accountServiceUrl = url.trimEnd('/')
+ }
+
+ /**
+ * Connect to the Message Router
+ */
+ fun connect(host: String, port: Int) {
+ grpcClient.connect(host, port)
+ }
+
+ /**
+ * Disconnect from the server
+ */
+ fun disconnect() {
+ messageCollectionJob?.cancel()
+ grpcClient.disconnect()
+ }
+
+ /**
+ * Register this party with the router
+ */
+ suspend fun registerParty(): String {
+ grpcClient.registerParty(partyId, "temporary", "1.0.0")
+ return partyId
+ }
+
+ /**
+ * Get share count for startup check
+ */
+ suspend fun getShareCount(): Int {
+ return shareRecordDao.getShareCount()
+ }
+
+ /**
+ * Check Kava blockchain health
+ */
+ suspend fun checkKavaHealth(): Boolean {
+ // TODO: Implement actual Kava RPC health check
+ return true
+ }
+
+ /**
+ * Create a new keygen session (as initiator)
+ * Calls account-service API: POST /api/v1/co-managed/sessions
+ */
+ suspend fun createKeygenSession(
+ walletName: String,
+ thresholdT: Int,
+ thresholdN: Int,
+ participantName: String
+ ): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ val jsonMediaType = "application/json; charset=utf-8".toMediaType()
+
+ // Build request body matching account-service API
+ val requestBody = com.google.gson.JsonObject().apply {
+ addProperty("wallet_name", walletName)
+ addProperty("threshold_t", thresholdT)
+ addProperty("threshold_n", thresholdN)
+ addProperty("initiator_party_id", partyId)
+ addProperty("initiator_name", participantName)
+ addProperty("persistent_count", 0) // All external participants
+ addProperty("external_count", thresholdN)
+ }.toString()
+
+ val request = okhttp3.Request.Builder()
+ .url("$accountServiceUrl/api/v1/co-managed/sessions")
+ .post(requestBody.toRequestBody(jsonMediaType))
+ .header("Content-Type", "application/json")
+ .build()
+
+ android.util.Log.d("TssRepository", "Creating keygen session: $requestBody")
+
+ val response = httpClient.newCall(request).execute()
+ val responseBody = response.body?.string()
+ ?: return@withContext Result.failure(Exception("空响应"))
+
+ android.util.Log.d("TssRepository", "Create session response: $responseBody")
+
+ if (!response.isSuccessful) {
+ val errorJson = try {
+ com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ } catch (e: Exception) { null }
+ val errorMsg = errorJson?.get("message")?.asString
+ ?: errorJson?.get("error")?.asString
+ ?: "HTTP ${response.code}"
+ return@withContext Result.failure(Exception(errorMsg))
+ }
+
+ // Parse response
+ val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ val inviteCode = json.get("invite_code").asString
+ val joinToken = json.get("join_token")?.asString
+ ?: json.get("join_tokens")?.asJsonObject?.entrySet()?.firstOrNull()?.value?.asString
+ ?: ""
+
+ // Return invite code in format: inviteCode (the API returns a ready-to-use invite code)
+ // The invite code can be used directly - joinToken is for direct session joining
+ Result.success(inviteCode)
+ } catch (e: Exception) {
+ android.util.Log.e("TssRepository", "Create keygen session failed", e)
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Get all stored shares
+ */
+ fun getAllShares(): Flow> {
+ return shareRecordDao.getAllShares().map { entities ->
+ entities.map { it.toShareRecord() }
+ }
+ }
+
+ /**
+ * Validate an invite code and get session info
+ * Calls account-service API: GET /api/v1/co-managed/sessions/by-invite-code/{inviteCode}
+ */
+ suspend fun validateInviteCode(inviteCode: String): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ val request = okhttp3.Request.Builder()
+ .url("$accountServiceUrl/api/v1/co-managed/sessions/by-invite-code/$inviteCode")
+ .get()
+ .build()
+
+ android.util.Log.d("TssRepository", "Validating invite code: $inviteCode")
+
+ val response = httpClient.newCall(request).execute()
+ val responseBody = response.body?.string()
+ ?: return@withContext Result.failure(Exception("空响应"))
+
+ android.util.Log.d("TssRepository", "Validate response: $responseBody")
+
+ if (!response.isSuccessful) {
+ val errorJson = try {
+ com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ } catch (e: Exception) { null }
+ val errorMsg = errorJson?.get("message")?.asString
+ ?: errorJson?.get("error")?.asString
+ ?: "HTTP ${response.code}"
+ return@withContext Result.failure(Exception(errorMsg))
+ }
+
+ // Parse response
+ val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ val sessionId = json.get("session_id").asString
+ val walletName = json.get("wallet_name")?.asString ?: ""
+ val thresholdT = json.get("threshold_t")?.asInt ?: 2
+ val thresholdN = json.get("threshold_n")?.asInt ?: 3
+ val joinedParties = json.get("joined_parties")?.asInt ?: 0
+ val joinToken = json.get("join_token")?.asString ?: ""
+
+ val sessionInfo = SessionInfoResponse(
+ sessionId = sessionId,
+ walletName = walletName,
+ thresholdT = thresholdT,
+ thresholdN = thresholdN,
+ initiator = "发起者", // API may not return this
+ currentParticipants = joinedParties,
+ totalParticipants = thresholdN
+ )
+
+ Result.success(ValidateInviteCodeResult(
+ sessionInfo = sessionInfo,
+ joinToken = joinToken
+ ))
+ } catch (e: Exception) {
+ android.util.Log.e("TssRepository", "Validate invite code failed", e)
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Validate a sign session invite code and get session info
+ * Calls account-service API: GET /api/v1/co-managed/sign/by-invite-code/{inviteCode}
+ */
+ suspend fun validateSignInviteCode(inviteCode: String): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ val request = okhttp3.Request.Builder()
+ .url("$accountServiceUrl/api/v1/co-managed/sign/by-invite-code/$inviteCode")
+ .get()
+ .build()
+
+ android.util.Log.d("TssRepository", "Validating sign invite code: $inviteCode")
+
+ val response = httpClient.newCall(request).execute()
+ val responseBody = response.body?.string()
+ ?: return@withContext Result.failure(Exception("空响应"))
+
+ android.util.Log.d("TssRepository", "Validate sign response: $responseBody")
+
+ if (!response.isSuccessful) {
+ val errorJson = try {
+ com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ } catch (e: Exception) { null }
+ val errorMsg = errorJson?.get("message")?.asString
+ ?: errorJson?.get("error")?.asString
+ ?: "HTTP ${response.code}"
+ return@withContext Result.failure(Exception(errorMsg))
+ }
+
+ // Parse response
+ val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ val sessionId = json.get("session_id").asString
+ val keygenSessionId = json.get("keygen_session_id")?.asString ?: ""
+ val walletName = json.get("wallet_name")?.asString ?: ""
+ val messageHash = json.get("message_hash")?.asString ?: ""
+ val thresholdT = json.get("threshold_t")?.asInt ?: 2
+ val thresholdN = json.get("threshold_n")?.asInt ?: 3
+ val joinedCount = json.get("joined_count")?.asInt ?: 0
+ val joinToken = json.get("join_token")?.asString ?: ""
+
+ val signSessionInfo = SignSessionInfoResponse(
+ sessionId = sessionId,
+ keygenSessionId = keygenSessionId,
+ walletName = walletName,
+ messageHash = messageHash,
+ thresholdT = thresholdT,
+ thresholdN = thresholdN,
+ currentParticipants = joinedCount
+ )
+
+ Result.success(ValidateSignInviteCodeResult(
+ signSessionInfo = signSessionInfo,
+ joinToken = joinToken
+ ))
+ } catch (e: Exception) {
+ android.util.Log.e("TssRepository", "Validate sign invite code failed", e)
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Join a keygen session and execute keygen protocol
+ * First calls account-service to get session info and join token, then joins via gRPC
+ */
+ suspend fun joinKeygenSession(
+ inviteCode: String,
+ password: String
+ ): Result = coroutineScope {
+ try {
+ android.util.Log.d("TssRepository", "Joining keygen session with invite code: $inviteCode")
+
+ // Step 1: Call account-service API to join session and get party_index
+ val joinApiResult = joinSessionViaApi(inviteCode)
+ if (joinApiResult.isFailure) {
+ return@coroutineScope Result.failure(joinApiResult.exceptionOrNull()!!)
+ }
+ val apiJoinData = joinApiResult.getOrThrow()
+
+ android.util.Log.d("TssRepository", "API join successful: sessionId=${apiJoinData.sessionId}, partyIndex=${apiJoinData.partyIndex}")
+
+ // Step 2: Join session via gRPC for message routing
+ // Use the join_token from API response
+ val joinResult = grpcClient.joinSession(apiJoinData.sessionId, partyId, apiJoinData.joinToken)
+ if (joinResult.isFailure) {
+ android.util.Log.e("TssRepository", "gRPC join failed", joinResult.exceptionOrNull())
+ return@coroutineScope Result.failure(joinResult.exceptionOrNull()!!)
+ }
+
+ val sessionData = joinResult.getOrThrow()
+
+ // Use party_index from API response (more reliable than gRPC response)
+ val myPartyIndex = apiJoinData.partyIndex
+
+ // Update session state
+ val session = TssSession(
+ sessionId = apiJoinData.sessionId,
+ sessionType = SessionType.KEYGEN,
+ thresholdT = apiJoinData.thresholdT,
+ thresholdN = apiJoinData.thresholdN,
+ participants = sessionData.participants,
+ status = SessionStatus.WAITING,
+ inviteCode = inviteCode
+ )
+ _currentSession.value = session
+ _sessionStatus.value = SessionStatus.WAITING
+
+ // Add self to participants
+ val allParticipants = sessionData.participants + Participant(partyId, myPartyIndex)
+
+ // Start TSS keygen
+ val startResult = tssNativeBridge.startKeygen(
+ sessionId = apiJoinData.sessionId,
+ partyId = partyId,
+ partyIndex = myPartyIndex,
+ thresholdT = apiJoinData.thresholdT,
+ thresholdN = apiJoinData.thresholdN,
+ participants = allParticipants,
+ password = password
+ )
+
+ if (startResult.isFailure) {
+ return@coroutineScope Result.failure(startResult.exceptionOrNull()!!)
+ }
+
+ _sessionStatus.value = SessionStatus.IN_PROGRESS
+
+ // Start message routing
+ startMessageRouting(apiJoinData.sessionId, myPartyIndex)
+
+ // Mark ready
+ grpcClient.markPartyReady(apiJoinData.sessionId, partyId)
+
+ // Wait for keygen result
+ val keygenResult = tssNativeBridge.waitForKeygenResult(password)
+ if (keygenResult.isFailure) {
+ _sessionStatus.value = SessionStatus.FAILED
+ return@coroutineScope Result.failure(keygenResult.exceptionOrNull()!!)
+ }
+
+ val result = keygenResult.getOrThrow()
+
+ // Derive address from public key
+ val publicKeyBytes = Base64.decode(result.publicKey, Base64.NO_WRAP)
+ val address = AddressUtils.deriveKavaAddress(publicKeyBytes)
+
+ // Save share record
+ val shareEntity = ShareRecordEntity(
+ sessionId = apiJoinData.sessionId,
+ publicKey = result.publicKey,
+ encryptedShare = result.encryptedShare,
+ thresholdT = apiJoinData.thresholdT,
+ thresholdN = apiJoinData.thresholdN,
+ partyIndex = myPartyIndex,
+ address = address
+ )
+ val id = shareRecordDao.insertShare(shareEntity)
+
+ // Report completion
+ grpcClient.reportCompletion(apiJoinData.sessionId, partyId, publicKeyBytes)
+
+ _sessionStatus.value = SessionStatus.COMPLETED
+ messageCollectionJob?.cancel()
+
+ Result.success(shareEntity.copy(id = id).toShareRecord())
+
+ } catch (e: Exception) {
+ android.util.Log.e("TssRepository", "Join keygen session failed", e)
+ _sessionStatus.value = SessionStatus.FAILED
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Join session via account-service HTTP API
+ * POST /api/v1/co-managed/sessions/{sessionId}/join
+ */
+ private suspend fun joinSessionViaApi(inviteCode: String): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ // First, get session info by invite code to get session_id and join_token
+ val sessionInfoResult = validateInviteCode(inviteCode)
+ if (sessionInfoResult.isFailure) {
+ return@withContext Result.failure(sessionInfoResult.exceptionOrNull()!!)
+ }
+ val sessionInfo = sessionInfoResult.getOrThrow()
+ val sessionId = sessionInfo.sessionInfo.sessionId
+ val joinToken = sessionInfo.joinToken
+
+ android.util.Log.d("TssRepository", "Got session info: sessionId=$sessionId, joinToken=$joinToken")
+
+ // Now call join API
+ val jsonMediaType = "application/json; charset=utf-8".toMediaType()
+ val requestBody = com.google.gson.JsonObject().apply {
+ addProperty("party_id", partyId)
+ addProperty("join_token", joinToken)
+ addProperty("device_type", "mobile")
+ addProperty("device_id", partyId)
+ }.toString()
+
+ val request = okhttp3.Request.Builder()
+ .url("$accountServiceUrl/api/v1/co-managed/sessions/$sessionId/join")
+ .post(requestBody.toRequestBody(jsonMediaType))
+ .header("Content-Type", "application/json")
+ .build()
+
+ android.util.Log.d("TssRepository", "Joining session via API: $requestBody")
+
+ val response = httpClient.newCall(request).execute()
+ val responseBody = response.body?.string()
+ ?: return@withContext Result.failure(Exception("空响应"))
+
+ android.util.Log.d("TssRepository", "Join API response: $responseBody")
+
+ if (!response.isSuccessful) {
+ val errorJson = try {
+ com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ } catch (e: Exception) { null }
+ val errorMsg = errorJson?.get("message")?.asString
+ ?: errorJson?.get("error")?.asString
+ ?: "HTTP ${response.code}"
+ return@withContext Result.failure(Exception(errorMsg))
+ }
+
+ // Parse response
+ val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ val partyIndex = json.get("party_index")?.asInt ?: 0
+ val sessionInfoJson = json.get("session_info")?.asJsonObject
+ val thresholdT = sessionInfoJson?.get("threshold_t")?.asInt ?: sessionInfo.sessionInfo.thresholdT
+ val thresholdN = sessionInfoJson?.get("threshold_n")?.asInt ?: sessionInfo.sessionInfo.thresholdN
+
+ Result.success(ApiJoinSessionData(
+ sessionId = sessionId,
+ partyIndex = partyIndex,
+ thresholdT = thresholdT,
+ thresholdN = thresholdN,
+ joinToken = joinToken
+ ))
+ } catch (e: Exception) {
+ android.util.Log.e("TssRepository", "Join session via API failed", e)
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Join a sign session and execute sign protocol
+ * First calls account-service to get session info and join, then joins via gRPC
+ */
+ suspend fun joinSignSession(
+ inviteCode: String,
+ shareId: Long,
+ password: String
+ ): Result = coroutineScope {
+ try {
+ android.util.Log.d("TssRepository", "Joining sign session with invite code: $inviteCode")
+
+ // Get share record
+ val shareEntity = shareRecordDao.getShareById(shareId)
+ ?: return@coroutineScope Result.failure(Exception("Share not found"))
+
+ // Step 1: Call account-service API to join sign session and get party info
+ val joinApiResult = joinSignSessionViaApi(inviteCode, shareEntity.partyIndex)
+ if (joinApiResult.isFailure) {
+ return@coroutineScope Result.failure(joinApiResult.exceptionOrNull()!!)
+ }
+ val apiJoinData = joinApiResult.getOrThrow()
+
+ android.util.Log.d("TssRepository", "API sign join successful: sessionId=${apiJoinData.sessionId}, partyIndex=${apiJoinData.partyIndex}, messageHash=${apiJoinData.messageHash}")
+
+ // Step 2: Join session via gRPC for message routing
+ val joinResult = grpcClient.joinSession(apiJoinData.sessionId, partyId, apiJoinData.joinToken)
+ if (joinResult.isFailure) {
+ android.util.Log.e("TssRepository", "gRPC join failed", joinResult.exceptionOrNull())
+ return@coroutineScope Result.failure(joinResult.exceptionOrNull()!!)
+ }
+
+ val sessionData = joinResult.getOrThrow()
+
+ // Use party_index from API response (more reliable)
+ val myPartyIndex = apiJoinData.partyIndex
+
+ // Update session state
+ val session = TssSession(
+ sessionId = apiJoinData.sessionId,
+ sessionType = SessionType.SIGN,
+ thresholdT = apiJoinData.thresholdT,
+ thresholdN = apiJoinData.thresholdN,
+ participants = sessionData.participants,
+ status = SessionStatus.WAITING,
+ inviteCode = inviteCode,
+ messageHash = apiJoinData.messageHash
+ )
+ _currentSession.value = session
+ _sessionStatus.value = SessionStatus.WAITING
+
+ // Add self to participants
+ val allParticipants = sessionData.participants + Participant(partyId, myPartyIndex)
+
+ // Start TSS sign
+ val startResult = tssNativeBridge.startSign(
+ sessionId = apiJoinData.sessionId,
+ partyId = partyId,
+ partyIndex = myPartyIndex,
+ thresholdT = apiJoinData.thresholdT,
+ thresholdN = shareEntity.thresholdN, // Use original N from keygen
+ participants = allParticipants,
+ messageHash = apiJoinData.messageHash,
+ shareData = shareEntity.encryptedShare,
+ password = password
+ )
+
+ if (startResult.isFailure) {
+ return@coroutineScope Result.failure(startResult.exceptionOrNull()!!)
+ }
+
+ _sessionStatus.value = SessionStatus.IN_PROGRESS
+
+ // Start message routing
+ startMessageRouting(apiJoinData.sessionId, myPartyIndex)
+
+ // Mark ready
+ grpcClient.markPartyReady(apiJoinData.sessionId, partyId)
+
+ // Wait for sign result
+ val signResult = tssNativeBridge.waitForSignResult()
+ if (signResult.isFailure) {
+ _sessionStatus.value = SessionStatus.FAILED
+ return@coroutineScope Result.failure(signResult.exceptionOrNull()!!)
+ }
+
+ val result = signResult.getOrThrow()
+
+ // Report completion
+ val signatureBytes = Base64.decode(result.signature, Base64.NO_WRAP)
+ grpcClient.reportCompletion(apiJoinData.sessionId, partyId, signature = signatureBytes)
+
+ _sessionStatus.value = SessionStatus.COMPLETED
+ messageCollectionJob?.cancel()
+
+ Result.success(result)
+
+ } catch (e: Exception) {
+ android.util.Log.e("TssRepository", "Join sign session failed", e)
+ _sessionStatus.value = SessionStatus.FAILED
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Join sign session via account-service HTTP API
+ * Uses validateSignInviteCode to get session info, then joins
+ */
+ private suspend fun joinSignSessionViaApi(inviteCode: String, partyIndex: Int): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ // First, get sign session info by invite code
+ val sessionInfoResult = validateSignInviteCode(inviteCode)
+ if (sessionInfoResult.isFailure) {
+ return@withContext Result.failure(sessionInfoResult.exceptionOrNull()!!)
+ }
+ val sessionInfo = sessionInfoResult.getOrThrow()
+ val signSessionInfo = sessionInfo.signSessionInfo
+ val sessionId = signSessionInfo.sessionId
+ val joinToken = sessionInfo.joinToken
+
+ android.util.Log.d("TssRepository", "Got sign session info: sessionId=$sessionId, messageHash=${signSessionInfo.messageHash}, joinToken=$joinToken")
+
+ // Now call join API (same endpoint as keygen join, but for sign sessions)
+ val jsonMediaType = "application/json; charset=utf-8".toMediaType()
+ val requestBody = com.google.gson.JsonObject().apply {
+ addProperty("party_id", partyId)
+ addProperty("join_token", joinToken)
+ addProperty("party_index", partyIndex)
+ addProperty("device_type", "mobile")
+ addProperty("device_id", partyId)
+ }.toString()
+
+ val request = okhttp3.Request.Builder()
+ .url("$accountServiceUrl/api/v1/co-managed/sign/$sessionId/join")
+ .post(requestBody.toRequestBody(jsonMediaType))
+ .header("Content-Type", "application/json")
+ .build()
+
+ android.util.Log.d("TssRepository", "Joining sign session via API: $requestBody")
+
+ val response = httpClient.newCall(request).execute()
+ val responseBody = response.body?.string()
+ ?: return@withContext Result.failure(Exception("空响应"))
+
+ android.util.Log.d("TssRepository", "Join sign API response: $responseBody")
+
+ if (!response.isSuccessful) {
+ val errorJson = try {
+ com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ } catch (e: Exception) { null }
+ val errorMsg = errorJson?.get("message")?.asString
+ ?: errorJson?.get("error")?.asString
+ ?: "HTTP ${response.code}"
+ return@withContext Result.failure(Exception(errorMsg))
+ }
+
+ // Parse response - extract party_index if provided, otherwise use the one from share
+ val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ val responsePartyIndex = json.get("party_index")?.asInt ?: partyIndex
+
+ Result.success(ApiJoinSignSessionData(
+ sessionId = sessionId,
+ partyIndex = responsePartyIndex,
+ thresholdT = signSessionInfo.thresholdT,
+ thresholdN = signSessionInfo.thresholdN,
+ messageHash = signSessionInfo.messageHash,
+ joinToken = joinToken
+ ))
+ } catch (e: Exception) {
+ android.util.Log.e("TssRepository", "Join sign session via API failed", e)
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Start message routing between TSS and gRPC
+ */
+ private fun startMessageRouting(sessionId: String, partyIndex: Int) {
+ messageCollectionJob?.cancel()
+ messageCollectionJob = CoroutineScope(Dispatchers.IO).launch {
+ // Collect outgoing messages from TSS and route via gRPC
+ launch {
+ tssNativeBridge.outgoingMessages.collect { message ->
+ val payload = Base64.decode(message.payload, Base64.NO_WRAP)
+ grpcClient.routeMessage(
+ sessionId = sessionId,
+ fromParty = partyId,
+ toParties = message.toParties ?: emptyList(),
+ roundNumber = 0,
+ messageType = if (message.isBroadcast) "broadcast" else "p2p",
+ payload = payload
+ )
+ }
+ }
+
+ // Collect incoming messages from gRPC and send to TSS
+ launch {
+ grpcClient.subscribeMessages(sessionId, partyId).collect { message ->
+ // Find party index from party ID
+ val session = _currentSession.value
+ val fromPartyIndex = session?.participants?.find { it.partyId == message.fromParty }?.partyIndex
+ ?: return@collect
+
+ tssNativeBridge.sendIncomingMessage(
+ fromPartyIndex = fromPartyIndex,
+ isBroadcast = message.isBroadcast,
+ payload = message.payload
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * Cancel current session
+ */
+ fun cancelSession() {
+ tssNativeBridge.cancelSession()
+ messageCollectionJob?.cancel()
+ _currentSession.value = null
+ _sessionStatus.value = SessionStatus.WAITING
+ }
+
+ /**
+ * Delete a share record
+ */
+ suspend fun deleteShare(id: Long) {
+ shareRecordDao.deleteShareById(id)
+ }
+
+ /**
+ * Get balance for an address using eth_getBalance RPC
+ */
+ suspend fun getBalance(address: String, rpcUrl: String): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ val client = okhttp3.OkHttpClient()
+ val jsonMediaType = "application/json; charset=utf-8".toMediaType()
+ val requestBody = """
+ {
+ "jsonrpc": "2.0",
+ "method": "eth_getBalance",
+ "params": ["$address", "latest"],
+ "id": 1
+ }
+ """.trimIndent()
+
+ val request = okhttp3.Request.Builder()
+ .url(rpcUrl)
+ .post(requestBody.toRequestBody(jsonMediaType))
+ .build()
+
+ val response = client.newCall(request).execute()
+ val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
+
+ // Parse JSON response
+ val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ if (json.has("error")) {
+ return@withContext Result.failure(Exception(json.get("error").asJsonObject.get("message").asString))
+ }
+
+ val hexBalance = json.get("result").asString
+ // Convert hex to decimal (wei) then to KAVA (18 decimals)
+ val weiBalance = java.math.BigInteger(hexBalance.removePrefix("0x"), 16)
+ val kavaBalance = java.math.BigDecimal(weiBalance).divide(java.math.BigDecimal("1000000000000000000"), 6, java.math.RoundingMode.DOWN)
+
+ Result.success(kavaBalance.toPlainString())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+ }
+
+ // ========== Transfer / Sign Session Methods ==========
+
+ /**
+ * Prepare a transaction for signing
+ */
+ suspend fun prepareTransaction(
+ from: String,
+ to: String,
+ amount: String,
+ rpcUrl: String,
+ chainId: Int = TransactionUtils.KAVA_TESTNET_CHAIN_ID
+ ): Result {
+ return TransactionUtils.prepareTransaction(
+ TransactionUtils.TransactionParams(
+ from = from,
+ to = to,
+ amount = amount,
+ rpcUrl = rpcUrl,
+ chainId = chainId
+ )
+ )
+ }
+
+ /**
+ * Create a sign session for a transaction (as initiator)
+ * Calls account-service API: POST /api/v1/co-managed/sign
+ */
+ suspend fun createSignSession(
+ shareId: Long,
+ messageHash: String,
+ password: String,
+ initiatorName: String
+ ): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ // Get share record
+ val shareEntity = shareRecordDao.getShareById(shareId)
+ ?: return@withContext Result.failure(Exception("Share not found"))
+
+ val jsonMediaType = "application/json; charset=utf-8".toMediaType()
+
+ // Build parties array - include initiator party info
+ val partiesArray = com.google.gson.JsonArray().apply {
+ add(com.google.gson.JsonObject().apply {
+ addProperty("party_id", partyId)
+ addProperty("party_index", shareEntity.partyIndex)
+ })
+ }
+
+ // Build request body matching account-service API
+ val requestBody = com.google.gson.JsonObject().apply {
+ addProperty("keygen_session_id", shareEntity.sessionId)
+ addProperty("wallet_name", "Wallet") // Use a default name or could be passed as parameter
+ addProperty("message_hash", messageHash)
+ add("parties", partiesArray)
+ addProperty("threshold_t", shareEntity.thresholdT)
+ addProperty("initiator_name", initiatorName)
+ }.toString()
+
+ val request = okhttp3.Request.Builder()
+ .url("$accountServiceUrl/api/v1/co-managed/sign")
+ .post(requestBody.toRequestBody(jsonMediaType))
+ .header("Content-Type", "application/json")
+ .build()
+
+ android.util.Log.d("TssRepository", "Creating sign session: $requestBody")
+
+ val response = httpClient.newCall(request).execute()
+ val responseBody = response.body?.string()
+ ?: return@withContext Result.failure(Exception("空响应"))
+
+ android.util.Log.d("TssRepository", "Create sign session response: $responseBody")
+
+ if (!response.isSuccessful) {
+ val errorJson = try {
+ com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ } catch (e: Exception) { null }
+ val errorMsg = errorJson?.get("message")?.asString
+ ?: errorJson?.get("error")?.asString
+ ?: "HTTP ${response.code}"
+ return@withContext Result.failure(Exception(errorMsg))
+ }
+
+ // Parse response
+ val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ val sessionId = json.get("session_id").asString
+ val inviteCode = json.get("invite_code").asString
+ val thresholdT = json.get("threshold_t")?.asInt ?: shareEntity.thresholdT
+
+ // Update session state
+ val session = TssSession(
+ sessionId = sessionId,
+ sessionType = SessionType.SIGN,
+ thresholdT = thresholdT,
+ thresholdN = shareEntity.thresholdN,
+ participants = listOf(Participant(partyId, shareEntity.partyIndex, initiatorName)),
+ status = SessionStatus.WAITING,
+ inviteCode = inviteCode,
+ messageHash = messageHash
+ )
+ _currentSession.value = session
+ _sessionStatus.value = SessionStatus.WAITING
+
+ Result.success(SignSessionResult(
+ sessionId = sessionId,
+ inviteCode = inviteCode
+ ))
+ } catch (e: Exception) {
+ android.util.Log.e("TssRepository", "Create sign session failed", e)
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Start the signing process after all parties have joined
+ */
+ suspend fun startSigning(
+ sessionId: String,
+ shareId: Long,
+ password: String
+ ): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ val session = _currentSession.value
+ ?: return@withContext Result.failure(Exception("No active session"))
+
+ val shareEntity = shareRecordDao.getShareById(shareId)
+ ?: return@withContext Result.failure(Exception("Share not found"))
+
+ // Start TSS sign
+ val startResult = tssNativeBridge.startSign(
+ sessionId = sessionId,
+ partyId = partyId,
+ partyIndex = shareEntity.partyIndex,
+ thresholdT = session.thresholdT,
+ thresholdN = shareEntity.thresholdN,
+ participants = session.participants,
+ messageHash = session.messageHash ?: "",
+ shareData = shareEntity.encryptedShare,
+ password = password
+ )
+
+ if (startResult.isFailure) {
+ return@withContext Result.failure(startResult.exceptionOrNull()!!)
+ }
+
+ _sessionStatus.value = SessionStatus.IN_PROGRESS
+
+ // Start message routing
+ startMessageRouting(sessionId, shareEntity.partyIndex)
+
+ // Mark ready
+ grpcClient.markPartyReady(sessionId, partyId)
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ _sessionStatus.value = SessionStatus.FAILED
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Wait for signing to complete and get signature
+ */
+ suspend fun waitForSignature(): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ val signResult = tssNativeBridge.waitForSignResult()
+ if (signResult.isFailure) {
+ _sessionStatus.value = SessionStatus.FAILED
+ return@withContext Result.failure(signResult.exceptionOrNull()!!)
+ }
+
+ val result = signResult.getOrThrow()
+
+ // Report completion
+ val signatureBytes = Base64.decode(result.signature, Base64.NO_WRAP)
+ val session = _currentSession.value
+ if (session != null) {
+ grpcClient.reportCompletion(session.sessionId, partyId, signature = signatureBytes)
+ }
+
+ _sessionStatus.value = SessionStatus.COMPLETED
+ messageCollectionJob?.cancel()
+
+ Result.success(result)
+ } catch (e: Exception) {
+ _sessionStatus.value = SessionStatus.FAILED
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Finalize and broadcast a signed transaction
+ */
+ suspend fun broadcastTransaction(
+ preparedTx: TransactionUtils.PreparedTransaction,
+ signature: String,
+ rpcUrl: String
+ ): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ // Parse signature (format: 0x + r(64) + s(64) + v(2))
+ val sigHex = signature.removePrefix("0x")
+ if (sigHex.length != 130) {
+ return@withContext Result.failure(Exception("Invalid signature length"))
+ }
+
+ val rHex = sigHex.substring(0, 64)
+ val sHex = sigHex.substring(64, 128)
+ val vHex = sigHex.substring(128, 130)
+
+ val r = rHex.hexToByteArray()
+ val s = sHex.hexToByteArray()
+ val v = vHex.toInt(16)
+ val recoveryId = if (v >= 27) v - 27 else v
+
+ // Finalize transaction with signature
+ val signedTx = TransactionUtils.finalizeTransaction(
+ preparedTx = preparedTx,
+ r = r,
+ s = s,
+ recoveryId = recoveryId
+ )
+
+ // Broadcast to network
+ TransactionUtils.broadcastTransaction(signedTx, rpcUrl)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Get transaction receipt
+ */
+ suspend fun getTransactionReceipt(
+ txHash: String,
+ rpcUrl: String
+ ): Result {
+ return TransactionUtils.getTransactionReceipt(txHash, rpcUrl)
+ }
+
+ // ========== Connection Test Methods ==========
+
+ /**
+ * Test Message Router connection
+ * Returns success/failure with latency
+ */
+ suspend fun testMessageRouter(serverUrl: String): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ val startTime = System.currentTimeMillis()
+
+ val parts = serverUrl.split(":")
+ val host = parts[0]
+ val port = parts.getOrNull(1)?.toIntOrNull() ?: 443
+
+ // Try to connect
+ grpcClient.connect(host, port)
+ val testPartyId = "test-${UUID.randomUUID()}"
+ grpcClient.registerParty(testPartyId, "test", "1.0.0")
+
+ val latency = System.currentTimeMillis() - startTime
+ Result.success(latency)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Test Account Service connection
+ * Makes a health check or simple GET request
+ */
+ suspend fun testAccountService(serviceUrl: String): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ val startTime = System.currentTimeMillis()
+
+ val client = okhttp3.OkHttpClient.Builder()
+ .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
+ .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
+ .build()
+
+ // Try health endpoint or root
+ val healthUrl = if (serviceUrl.endsWith("/")) {
+ "${serviceUrl}health"
+ } else {
+ "$serviceUrl/health"
+ }
+
+ val request = okhttp3.Request.Builder()
+ .url(healthUrl)
+ .get()
+ .build()
+
+ val response = client.newCall(request).execute()
+ val latency = System.currentTimeMillis() - startTime
+
+ // Accept 200, 404 (endpoint may not exist but server is up)
+ if (response.isSuccessful || response.code == 404) {
+ Result.success(latency)
+ } else {
+ Result.failure(Exception("HTTP ${response.code}"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Test Kava RPC connection
+ * Makes an eth_chainId RPC call
+ */
+ suspend fun testKavaApi(rpcUrl: String): Result {
+ return withContext(Dispatchers.IO) {
+ try {
+ val startTime = System.currentTimeMillis()
+
+ val client = okhttp3.OkHttpClient.Builder()
+ .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
+ .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
+ .build()
+
+ val jsonMediaType = "application/json; charset=utf-8".toMediaType()
+ val requestBody = """
+ {
+ "jsonrpc": "2.0",
+ "method": "eth_chainId",
+ "params": [],
+ "id": 1
+ }
+ """.trimIndent()
+
+ val request = okhttp3.Request.Builder()
+ .url(rpcUrl)
+ .post(requestBody.toRequestBody(jsonMediaType))
+ .build()
+
+ val response = client.newCall(request).execute()
+ val latency = System.currentTimeMillis() - startTime
+
+ if (!response.isSuccessful) {
+ return@withContext Result.failure(Exception("HTTP ${response.code}"))
+ }
+
+ val responseBody = response.body?.string()
+ ?: return@withContext Result.failure(Exception("空响应"))
+
+ // Parse JSON to verify it's a valid JSON-RPC response
+ val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
+ if (json.has("error")) {
+ return@withContext Result.failure(
+ Exception(json.get("error").asJsonObject.get("message").asString)
+ )
+ }
+
+ Result.success(latency)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+ }
+
+ /**
+ * Get a share by ID
+ */
+ suspend fun getShareById(shareId: Long): ShareRecord? {
+ return shareRecordDao.getShareById(shareId)?.toShareRecord()
+ }
+
+ private fun String.hexToByteArray(): ByteArray {
+ val len = this.length
+ val data = ByteArray(len / 2)
+ var i = 0
+ while (i < len) {
+ data[i / 2] = ((Character.digit(this[i], 16) shl 4) + Character.digit(this[i + 1], 16)).toByte()
+ i += 2
+ }
+ return data
+ }
+}
+
+/**
+ * Result of creating a sign session
+ */
+data class SignSessionResult(
+ val sessionId: String,
+ val inviteCode: String
+)
+
+/**
+ * Result of validating an invite code
+ */
+data class ValidateInviteCodeResult(
+ val sessionInfo: SessionInfoResponse,
+ val joinToken: String
+)
+
+/**
+ * Session info from validateInviteCode API
+ */
+data class SessionInfoResponse(
+ val sessionId: String,
+ val walletName: String,
+ val thresholdT: Int,
+ val thresholdN: Int,
+ val initiator: String,
+ val currentParticipants: Int,
+ val totalParticipants: Int
+)
+
+/**
+ * Result of validating a sign session invite code
+ */
+data class ValidateSignInviteCodeResult(
+ val signSessionInfo: SignSessionInfoResponse,
+ val joinToken: String
+)
+
+/**
+ * Sign session info from validateSignInviteCode API
+ */
+data class SignSessionInfoResponse(
+ val sessionId: String,
+ val keygenSessionId: String,
+ val walletName: String,
+ val messageHash: String,
+ val thresholdT: Int,
+ val thresholdN: Int,
+ val currentParticipants: Int
+)
+
+/**
+ * Data returned from joinSessionViaApi
+ */
+data class ApiJoinSessionData(
+ val sessionId: String,
+ val partyIndex: Int,
+ val thresholdT: Int,
+ val thresholdN: Int,
+ val joinToken: String
+)
+
+/**
+ * Data returned from joinSignSessionViaApi
+ */
+data class ApiJoinSignSessionData(
+ val sessionId: String,
+ val partyIndex: Int,
+ val thresholdT: Int,
+ val thresholdN: Int,
+ val messageHash: String,
+ val joinToken: String
+)
+
+private fun ShareRecordEntity.toShareRecord() = ShareRecord(
+ id = id,
+ sessionId = sessionId,
+ publicKey = publicKey,
+ encryptedShare = encryptedShare,
+ thresholdT = thresholdT,
+ thresholdN = thresholdN,
+ partyIndex = partyIndex,
+ address = address,
+ createdAt = createdAt
+)
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/di/AppModule.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/di/AppModule.kt
new file mode 100644
index 00000000..c3652dd4
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/di/AppModule.kt
@@ -0,0 +1,66 @@
+package com.durian.tssparty.di
+
+import android.content.Context
+import androidx.room.Room
+import com.durian.tssparty.data.local.ShareRecordDao
+import com.durian.tssparty.data.local.TssDatabase
+import com.durian.tssparty.data.local.TssNativeBridge
+import com.durian.tssparty.data.remote.GrpcClient
+import com.durian.tssparty.data.repository.TssRepository
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+ @Provides
+ @Singleton
+ fun provideGson(): Gson {
+ return GsonBuilder().create()
+ }
+
+ @Provides
+ @Singleton
+ fun provideDatabase(@ApplicationContext context: Context): TssDatabase {
+ return Room.databaseBuilder(
+ context,
+ TssDatabase::class.java,
+ "tss_party.db"
+ ).build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideShareRecordDao(database: TssDatabase): ShareRecordDao {
+ return database.shareRecordDao()
+ }
+
+ @Provides
+ @Singleton
+ fun provideGrpcClient(): GrpcClient {
+ return GrpcClient()
+ }
+
+ @Provides
+ @Singleton
+ fun provideTssNativeBridge(gson: Gson): TssNativeBridge {
+ return TssNativeBridge(gson)
+ }
+
+ @Provides
+ @Singleton
+ fun provideTssRepository(
+ grpcClient: GrpcClient,
+ tssNativeBridge: TssNativeBridge,
+ shareRecordDao: ShareRecordDao
+ ): TssRepository {
+ return TssRepository(grpcClient, tssNativeBridge, shareRecordDao)
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/AppState.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/AppState.kt
new file mode 100644
index 00000000..2d19358d
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/AppState.kt
@@ -0,0 +1,58 @@
+package com.durian.tssparty.domain.model
+
+/**
+ * Application ready state
+ */
+enum class AppReadyState {
+ INITIALIZING,
+ READY,
+ ERROR
+}
+
+/**
+ * Service check status
+ */
+data class ServiceStatus(
+ val isOnline: Boolean = false,
+ val message: String = "",
+ val latency: Long? = null
+)
+
+/**
+ * Environment state - tracks all service statuses
+ */
+data class EnvironmentState(
+ val database: ServiceStatus = ServiceStatus(),
+ val messageRouter: ServiceStatus = ServiceStatus(),
+ val kavaApi: ServiceStatus = ServiceStatus()
+)
+
+/**
+ * Operation progress for keygen/sign
+ */
+data class OperationProgress(
+ val isActive: Boolean = false,
+ val type: OperationType = OperationType.NONE,
+ val sessionId: String? = null,
+ val currentRound: Int = 0,
+ val totalRounds: Int = 0,
+ val status: String = ""
+)
+
+enum class OperationType {
+ NONE,
+ KEYGEN,
+ SIGN
+}
+
+/**
+ * Global app state (similar to Zustand store in Electron version)
+ */
+data class AppState(
+ val appReady: AppReadyState = AppReadyState.INITIALIZING,
+ val appError: String? = null,
+ val environment: EnvironmentState = EnvironmentState(),
+ val operation: OperationProgress = OperationProgress(),
+ val partyId: String? = null,
+ val walletCount: Int = 0
+)
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt
new file mode 100644
index 00000000..19c89fd4
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt
@@ -0,0 +1,125 @@
+package com.durian.tssparty.domain.model
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * Participant in a TSS session
+ */
+data class Participant(
+ @SerializedName("partyId")
+ val partyId: String,
+ @SerializedName("partyIndex")
+ val partyIndex: Int,
+ @SerializedName("name")
+ val name: String = ""
+)
+
+/**
+ * TSS Session information
+ */
+data class TssSession(
+ val sessionId: String,
+ val sessionType: SessionType,
+ val thresholdT: Int,
+ val thresholdN: Int,
+ val participants: List,
+ val status: SessionStatus,
+ val inviteCode: String? = null,
+ val messageHash: String? = null,
+ val createdAt: Long = System.currentTimeMillis()
+)
+
+enum class SessionType {
+ KEYGEN,
+ SIGN
+}
+
+enum class SessionStatus {
+ WAITING,
+ IN_PROGRESS,
+ COMPLETED,
+ FAILED
+}
+
+/**
+ * Result of key generation
+ */
+data class KeygenResult(
+ @SerializedName("publicKey")
+ val publicKey: String, // base64 encoded
+ @SerializedName("encryptedShare")
+ val encryptedShare: String // base64 encoded
+)
+
+/**
+ * Result of signing
+ */
+data class SignResult(
+ @SerializedName("signature")
+ val signature: String, // base64 encoded (r || s || v, 65 bytes)
+ @SerializedName("recoveryId")
+ val recoveryId: Int
+)
+
+/**
+ * Outgoing TSS message
+ */
+data class TssOutgoingMessage(
+ @SerializedName("type")
+ val type: String,
+ @SerializedName("isBroadcast")
+ val isBroadcast: Boolean,
+ @SerializedName("toParties")
+ val toParties: List?,
+ @SerializedName("payload")
+ val payload: String // base64 encoded
+)
+
+/**
+ * Share record stored in local database
+ */
+data class ShareRecord(
+ val id: Long = 0,
+ val sessionId: String,
+ val publicKey: String,
+ val encryptedShare: String,
+ val thresholdT: Int,
+ val thresholdN: Int,
+ val partyIndex: Int,
+ val address: String,
+ val createdAt: Long = System.currentTimeMillis()
+)
+
+/**
+ * Account balance information
+ */
+data class AccountBalance(
+ val address: String,
+ val balance: String,
+ val denom: String = "ukava"
+)
+
+/**
+ * Sign session request
+ */
+data class SignSessionRequest(
+ val sessionId: String,
+ val messageHash: String, // hex encoded
+ val participants: List
+)
+
+/**
+ * Settings
+ * Matches service-party-app settings structure
+ */
+data class AppSettings(
+ val messageRouterUrl: String = "mpc-grpc.szaiai.com:443",
+ val accountServiceUrl: String = "https://rwaapi.szaiai.com",
+ val kavaRpcUrl: String = "https://evm.kava.io",
+ val networkType: NetworkType = NetworkType.MAINNET
+)
+
+enum class NetworkType {
+ MAINNET,
+ TESTNET
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/components/BottomNavigation.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/components/BottomNavigation.kt
new file mode 100644
index 00000000..0f1d2c32
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/components/BottomNavigation.kt
@@ -0,0 +1,85 @@
+package com.durian.tssparty.presentation.components
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.ImageVector
+
+/**
+ * Navigation destinations for bottom tabs
+ */
+sealed class BottomNavItem(
+ val route: String,
+ val title: String,
+ val selectedIcon: ImageVector,
+ val unselectedIcon: ImageVector
+) {
+ data object Wallets : BottomNavItem(
+ route = "wallets",
+ title = "我的钱包",
+ selectedIcon = Icons.Filled.Lock,
+ unselectedIcon = Icons.Outlined.Lock
+ )
+
+ data object Create : BottomNavItem(
+ route = "create",
+ title = "创建钱包",
+ selectedIcon = Icons.Filled.Add,
+ unselectedIcon = Icons.Outlined.Add
+ )
+
+ data object JoinKeygen : BottomNavItem(
+ route = "join_keygen",
+ title = "加入创建",
+ selectedIcon = Icons.Filled.Handshake,
+ unselectedIcon = Icons.Outlined.Handshake
+ )
+
+ data object CoSign : BottomNavItem(
+ route = "cosign",
+ title = "参与签名",
+ selectedIcon = Icons.Filled.Create,
+ unselectedIcon = Icons.Outlined.Create
+ )
+
+ data object Settings : BottomNavItem(
+ route = "settings",
+ title = "设置",
+ selectedIcon = Icons.Filled.Settings,
+ unselectedIcon = Icons.Outlined.Settings
+ )
+}
+
+val bottomNavItems = listOf(
+ BottomNavItem.Wallets,
+ BottomNavItem.JoinKeygen,
+ BottomNavItem.CoSign,
+ BottomNavItem.Settings
+)
+
+@Composable
+fun TssBottomNavigation(
+ currentRoute: String,
+ onNavigate: (BottomNavItem) -> Unit
+) {
+ NavigationBar {
+ bottomNavItems.forEach { item ->
+ val selected = currentRoute == item.route ||
+ (item == BottomNavItem.Wallets && currentRoute.startsWith("wallet_detail"))
+
+ NavigationBarItem(
+ icon = {
+ Icon(
+ imageVector = if (selected) item.selectedIcon else item.unselectedIcon,
+ contentDescription = item.title
+ )
+ },
+ label = { Text(item.title) },
+ selected = selected,
+ onClick = { onNavigate(item) }
+ )
+ }
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CoSignJoinScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CoSignJoinScreen.kt
new file mode 100644
index 00000000..404db302
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CoSignJoinScreen.kt
@@ -0,0 +1,865 @@
+package com.durian.tssparty.presentation.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.durian.tssparty.domain.model.SessionStatus
+import com.durian.tssparty.domain.model.ShareRecord
+
+/**
+ * Sign session info returned from validateSignInviteCode API
+ * Matches service-party-app SignSessionInfo type
+ */
+data class SignSessionInfo(
+ val sessionId: String,
+ val keygenSessionId: String,
+ val walletName: String,
+ val messageHash: String,
+ val thresholdT: Int,
+ val thresholdN: Int,
+ val currentParticipants: Int
+)
+
+/**
+ * CoSign Join screen matching service-party-app/src/renderer/src/pages/CoSignJoin.tsx
+ * 2-step flow: input → select_share → (auto-join) → signing → completed
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CoSignJoinScreen(
+ shares: List,
+ sessionStatus: SessionStatus,
+ isLoading: Boolean,
+ error: String?,
+ signSessionInfo: SignSessionInfo? = null,
+ participants: List = emptyList(),
+ currentRound: Int = 0,
+ totalRounds: Int = 9,
+ signature: String? = null,
+ onValidateInviteCode: (inviteCode: String) -> Unit,
+ onJoinSign: (inviteCode: String, shareId: Long, password: String) -> Unit,
+ onCancel: () -> Unit,
+ onBackToHome: () -> Unit = {}
+) {
+ var inviteCode by remember { mutableStateOf("") }
+ var selectedShareId by remember { mutableStateOf(null) }
+ var password by remember { mutableStateOf("") }
+ var showPassword by remember { mutableStateOf(false) }
+ var validationError by remember { mutableStateOf(null) }
+
+ // 2-step flow: input → select_share → joining → signing → completed
+ var step by remember { mutableStateOf("input") }
+ var autoJoinAttempted by remember { mutableStateOf(false) }
+
+ // Handle session info received (validation success)
+ LaunchedEffect(signSessionInfo) {
+ if (signSessionInfo != null && step == "input") {
+ step = "select_share"
+
+ // Auto-select matching share
+ val matchingShare = shares.find { it.sessionId == signSessionInfo.keygenSessionId }
+ if (matchingShare != null) {
+ selectedShareId = matchingShare.id
+ } else if (shares.size == 1) {
+ // Auto-select if only one share
+ selectedShareId = shares.first().id
+ }
+ }
+ }
+
+ // Auto-join when we have session info and selected share
+ LaunchedEffect(step, signSessionInfo, selectedShareId, autoJoinAttempted, isLoading) {
+ if (step == "select_share" && signSessionInfo != null &&
+ selectedShareId != null && !autoJoinAttempted && !isLoading && error == null) {
+ // Check if we should auto-join (matching share found)
+ val matchingShare = shares.find { it.sessionId == signSessionInfo.keygenSessionId }
+ if (matchingShare != null && selectedShareId == matchingShare.id) {
+ autoJoinAttempted = true
+ step = "joining"
+ onJoinSign(inviteCode, selectedShareId!!, password)
+ }
+ }
+ }
+
+ // Handle session status changes
+ LaunchedEffect(sessionStatus) {
+ when (sessionStatus) {
+ SessionStatus.IN_PROGRESS -> {
+ step = "signing"
+ }
+ SessionStatus.COMPLETED -> {
+ step = "completed"
+ }
+ SessionStatus.FAILED -> {
+ if (step == "joining" || step == "signing") {
+ step = "select_share"
+ autoJoinAttempted = false
+ }
+ }
+ else -> {}
+ }
+ }
+
+ // Reset auto-join on error
+ LaunchedEffect(error) {
+ if (error != null && (step == "joining" || step == "signing")) {
+ step = "select_share"
+ autoJoinAttempted = false
+ }
+ }
+
+ when (step) {
+ "input" -> InputScreen(
+ inviteCode = inviteCode,
+ isLoading = isLoading,
+ error = error,
+ validationError = validationError,
+ onInviteCodeChange = { inviteCode = it.uppercase() },
+ onValidateCode = {
+ when {
+ inviteCode.isBlank() -> validationError = "请输入邀请码"
+ else -> {
+ validationError = null
+ onValidateInviteCode(inviteCode)
+ }
+ }
+ },
+ onCancel = onCancel
+ )
+ "select_share" -> SelectShareScreen(
+ shares = shares,
+ signSessionInfo = signSessionInfo,
+ selectedShareId = selectedShareId,
+ password = password,
+ showPassword = showPassword,
+ isLoading = isLoading,
+ error = error,
+ validationError = validationError,
+ onShareSelected = { selectedShareId = it },
+ onPasswordChange = { password = it },
+ onTogglePassword = { showPassword = !showPassword },
+ onBack = {
+ step = "input"
+ autoJoinAttempted = false
+ validationError = null
+ },
+ onJoinSign = {
+ when {
+ selectedShareId == null -> validationError = "请选择一个钱包"
+ else -> {
+ validationError = null
+ autoJoinAttempted = true
+ step = "joining"
+ onJoinSign(inviteCode, selectedShareId!!, password)
+ }
+ }
+ }
+ )
+ "joining" -> JoiningScreen()
+ "signing" -> SigningProgressScreen(
+ sessionStatus = sessionStatus,
+ participants = participants,
+ currentRound = currentRound,
+ totalRounds = totalRounds,
+ onCancel = onCancel
+ )
+ "completed" -> SigningCompletedScreen(
+ signature = signature,
+ onBackToHome = onBackToHome
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun InputScreen(
+ inviteCode: String,
+ isLoading: Boolean,
+ error: String?,
+ validationError: String?,
+ onInviteCodeChange: (String) -> Unit,
+ onValidateCode: () -> Unit,
+ onCancel: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp)
+ ) {
+ // Header
+ Text(
+ text = "加入多方签名",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "输入邀请码加入签名会话",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Invite Code Input
+ OutlinedTextField(
+ value = inviteCode,
+ onValueChange = onInviteCodeChange,
+ label = { Text("邀请码") },
+ placeholder = { Text("粘贴邀请码或邀请链接") },
+ leadingIcon = {
+ Icon(Icons.Default.QrCode, contentDescription = null)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !isLoading
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Error display
+ error?.let {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ validationError?.let {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onCancel,
+ modifier = Modifier.weight(1f),
+ enabled = !isLoading
+ ) {
+ Text("取消")
+ }
+
+ Button(
+ onClick = onValidateCode,
+ modifier = Modifier.weight(1f),
+ enabled = !isLoading && inviteCode.isNotBlank()
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("验证中...")
+ } else {
+ Text("下一步")
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SelectShareScreen(
+ shares: List,
+ signSessionInfo: SignSessionInfo?,
+ selectedShareId: Long?,
+ password: String,
+ showPassword: Boolean,
+ isLoading: Boolean,
+ error: String?,
+ validationError: String?,
+ onShareSelected: (Long) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onTogglePassword: () -> Unit,
+ onBack: () -> Unit,
+ onJoinSign: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp)
+ ) {
+ // Header
+ Text(
+ text = "加入多方签名",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Session info card
+ if (signSessionInfo != null) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "签名会话信息",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ InfoRow("钱包名称", signSessionInfo.walletName)
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+ InfoRow("签名阈值", "${signSessionInfo.thresholdT}-of-${signSessionInfo.thresholdN}")
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+ InfoRow("消息哈希", "${signSessionInfo.messageHash.take(16)}...")
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+ InfoRow("当前参与者", "${signSessionInfo.currentParticipants} / ${signSessionInfo.thresholdT}")
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Select Wallet
+ Text(
+ text = "选择本地钱包",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (shares.isEmpty()) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ Icons.Default.AccountBalanceWallet,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.outline
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = "暂无可用钱包",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.outline
+ )
+ Text(
+ text = "请先创建或加入一个密钥生成会话",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ } else {
+ // Wallet selection cards
+ shares.forEach { share ->
+ val isSelected = selectedShareId == share.id
+ val isMatching = signSessionInfo?.keygenSessionId == share.sessionId
+
+ Card(
+ onClick = { onShareSelected(share.id) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSelected)
+ MaterialTheme.colorScheme.primaryContainer
+ else
+ MaterialTheme.colorScheme.surface
+ ),
+ border = if (isSelected)
+ CardDefaults.outlinedCardBorder()
+ else
+ null
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = isSelected,
+ onClick = { onShareSelected(share.id) }
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = share.address.take(10) + "..." + share.address.takeLast(8),
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
+ )
+ if (isMatching) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Surface(
+ color = MaterialTheme.colorScheme.primary,
+ shape = MaterialTheme.shapes.small
+ ) {
+ Text(
+ text = "匹配",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "${share.thresholdT}-of-${share.thresholdN} | Party #${share.partyIndex}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ if (isSelected) {
+ Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Password Input (optional)
+ OutlinedTextField(
+ value = password,
+ onValueChange = onPasswordChange,
+ label = { Text("钱包密码 (可选)") },
+ placeholder = { Text("如果设置了密码,请输入") },
+ leadingIcon = {
+ Icon(Icons.Default.Lock, contentDescription = null)
+ },
+ trailingIcon = {
+ IconButton(onClick = onTogglePassword) {
+ Icon(
+ imageVector = if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
+ contentDescription = null
+ )
+ }
+ },
+ visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !isLoading
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Error display
+ error?.let {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ validationError?.let {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onBack,
+ modifier = Modifier.weight(1f),
+ enabled = !isLoading
+ ) {
+ Text("返回")
+ }
+
+ Button(
+ onClick = onJoinSign,
+ modifier = Modifier.weight(1f),
+ enabled = !isLoading && shares.isNotEmpty() && selectedShareId != null
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("加入中...")
+ } else {
+ Icon(Icons.Default.Create, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("加入签名")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun JoiningScreen() {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(80.dp),
+ strokeWidth = 6.dp
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Text(
+ text = "正在加入签名会话...",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "请稍候,正在连接到其他签名参与者",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+private fun SigningProgressScreen(
+ sessionStatus: SessionStatus,
+ participants: List,
+ currentRound: Int,
+ totalRounds: Int,
+ onCancel: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Header
+ Text(
+ text = "签名进行中",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "请保持应用在前台,直到签名完成",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Progress indicator
+ CircularProgressIndicator(
+ modifier = Modifier.size(80.dp),
+ strokeWidth = 6.dp
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Progress card
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "签名进度",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = "轮次 $currentRound / $totalRounds",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ LinearProgressIndicator(
+ progress = if (totalRounds > 0) currentRound.toFloat() / totalRounds else 0f,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Participants card
+ if (participants.isNotEmpty()) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "签名参与方 (${participants.size})",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ participants.forEach { participant ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Person,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = participant,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Cancel button
+ OutlinedButton(onClick = onCancel) {
+ Icon(Icons.Default.Cancel, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("取消")
+ }
+ }
+}
+
+@Composable
+private fun SigningCompletedScreen(
+ signature: String?,
+ onBackToHome: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ // Success icon
+ Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(80.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = "签名完成!",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "多方签名已成功完成",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Signature info
+ if (signature != null) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "签名结果",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "${signature.take(32)}...${signature.takeLast(32)}",
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Button(
+ onClick = onBackToHome,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(Icons.Default.Home, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("返回首页")
+ }
+ }
+}
+
+@Composable
+private fun InfoRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CreateWalletScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CreateWalletScreen.kt
new file mode 100644
index 00000000..965d4638
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CreateWalletScreen.kt
@@ -0,0 +1,989 @@
+package com.durian.tssparty.presentation.screens
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.durian.tssparty.domain.model.SessionStatus
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.qrcode.QRCodeWriter
+
+/**
+ * Create Wallet Screen - matches service-party-app Create.tsx exactly
+ *
+ * Flow:
+ * 1. config - Configuration form (wallet name, threshold, participant name)
+ * 2. creating - Loading state
+ * 3. created - Show invite code
+ * 4. session - Navigate to session page (handled by navigation)
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CreateWalletScreen(
+ isLoading: Boolean,
+ error: String?,
+ inviteCode: String?,
+ sessionId: String?,
+ sessionStatus: SessionStatus,
+ participants: List = emptyList(),
+ currentRound: Int = 0,
+ totalRounds: Int = 9,
+ publicKey: String? = null,
+ onCreateSession: (walletName: String, thresholdT: Int, thresholdN: Int, participantName: String) -> Unit,
+ onCopyInviteCode: () -> Unit,
+ onEnterSession: () -> Unit,
+ onCancel: () -> Unit,
+ onBackToHome: () -> Unit
+) {
+ var walletName by remember { mutableStateOf("") }
+ var thresholdT by remember { mutableIntStateOf(3) }
+ var thresholdN by remember { mutableIntStateOf(5) }
+ var participantName by remember { mutableStateOf("") }
+ var validationError by remember { mutableStateOf(null) }
+
+ // Determine current step based on state
+ val step = when {
+ sessionStatus == SessionStatus.IN_PROGRESS || sessionStatus == SessionStatus.COMPLETED || sessionStatus == SessionStatus.FAILED -> "session"
+ inviteCode != null -> "created"
+ isLoading -> "creating"
+ else -> "config"
+ }
+
+ when (step) {
+ "config" -> ConfigScreen(
+ walletName = walletName,
+ onWalletNameChange = { walletName = it },
+ thresholdT = thresholdT,
+ onThresholdTChange = { thresholdT = it },
+ thresholdN = thresholdN,
+ onThresholdNChange = { thresholdN = it },
+ participantName = participantName,
+ onParticipantNameChange = { participantName = it },
+ error = error ?: validationError,
+ onCreateSession = {
+ // Validate inputs
+ when {
+ walletName.isBlank() -> validationError = "请输入钱包名称"
+ participantName.isBlank() -> validationError = "请输入您的名称"
+ thresholdT < 1 -> validationError = "签名阈值至少为 1"
+ thresholdN < 2 -> validationError = "参与方总数至少为 2"
+ thresholdT > thresholdN -> validationError = "签名阈值不能大于参与方总数"
+ else -> {
+ validationError = null
+ onCreateSession(walletName.trim(), thresholdT, thresholdN, participantName.trim())
+ }
+ }
+ },
+ onCancel = onCancel
+ )
+ "creating" -> CreatingScreen()
+ "created" -> CreatedScreen(
+ inviteCode = inviteCode!!,
+ onCopyInviteCode = onCopyInviteCode,
+ onEnterSession = onEnterSession
+ )
+ "session" -> SessionScreen(
+ walletName = walletName,
+ sessionId = sessionId ?: "",
+ sessionStatus = sessionStatus,
+ participants = participants,
+ thresholdT = thresholdT,
+ thresholdN = thresholdN,
+ currentRound = currentRound,
+ totalRounds = totalRounds,
+ publicKey = publicKey,
+ inviteCode = inviteCode,
+ onCopyInviteCode = onCopyInviteCode,
+ onBackToHome = onBackToHome
+ )
+ }
+}
+
+@Composable
+private fun ConfigScreen(
+ walletName: String,
+ onWalletNameChange: (String) -> Unit,
+ thresholdT: Int,
+ onThresholdTChange: (Int) -> Unit,
+ thresholdN: Int,
+ onThresholdNChange: (Int) -> Unit,
+ participantName: String,
+ onParticipantNameChange: (String) -> Unit,
+ error: String?,
+ onCreateSession: () -> Unit,
+ onCancel: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp)
+ ) {
+ // Header
+ Text(
+ text = "创建共管钱包",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = "3-of-5 混合托管模式 - 设置钱包参数并邀请参与方",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Info Box - Hybrid Custody Mode Explanation
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.Info,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "混合托管模式说明",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "• 5 个密钥份额:2 个平台备份 + 3 个用户持有\n" +
+ "• 日常签名:仅需 3 个用户参与\n" +
+ "• 密钥恢复:2 个用户可丢失密钥,使用平台备份轮换\n" +
+ "• 安全保障:平台备份仅用于紧急恢复,不参与日常签名",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Wallet Name Input
+ OutlinedTextField(
+ value = walletName,
+ onValueChange = onWalletNameChange,
+ label = { Text("钱包名称") },
+ placeholder = { Text("为您的共管钱包命名") },
+ leadingIcon = {
+ Icon(Icons.Default.AccountBalanceWallet, contentDescription = null)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Threshold Configuration
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = "阈值设置 (T-of-N)",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Threshold T
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "签名阈值 (T)",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(
+ onClick = { if (thresholdT > 1) onThresholdTChange(thresholdT - 1) }
+ ) {
+ Icon(Icons.Default.Remove, contentDescription = "减少")
+ }
+ Text(
+ text = thresholdT.toString(),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ IconButton(
+ onClick = { if (thresholdT < thresholdN) onThresholdTChange(thresholdT + 1) }
+ ) {
+ Icon(Icons.Default.Add, contentDescription = "增加")
+ }
+ }
+ }
+
+ Text(
+ text = "of",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Threshold N
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "参与方总数 (N)",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(
+ onClick = {
+ if (thresholdN > 2) {
+ onThresholdNChange(thresholdN - 1)
+ // Cap T if it exceeds new N
+ if (thresholdT > thresholdN - 1) {
+ onThresholdTChange(thresholdN - 1)
+ }
+ }
+ }
+ ) {
+ Icon(Icons.Default.Remove, contentDescription = "减少")
+ }
+ Text(
+ text = thresholdN.toString(),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ IconButton(
+ onClick = { if (thresholdN < 10) onThresholdNChange(thresholdN + 1) }
+ ) {
+ Icon(Icons.Default.Add, contentDescription = "增加")
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "需要 $thresholdT 个参与方共同签名才能执行交易 (其中 2 个由平台托管用于备份)",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Participant Name Input
+ OutlinedTextField(
+ value = participantName,
+ onValueChange = onParticipantNameChange,
+ label = { Text("您的名称") },
+ placeholder = { Text("输入您的名称(其他参与者可见)") },
+ leadingIcon = {
+ Icon(Icons.Default.Person, contentDescription = null)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Error display
+ error?.let {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Action Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onCancel,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("取消")
+ }
+ Button(
+ onClick = onCreateSession,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("创建会话")
+ }
+ }
+ }
+}
+
+@Composable
+private fun CreatingScreen() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(40.dp),
+ strokeWidth = 3.dp
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "正在创建会话...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+@Composable
+private fun CreatedScreen(
+ inviteCode: String,
+ onCopyInviteCode: () -> Unit,
+ onEnterSession: () -> Unit
+) {
+ val clipboardManager = LocalClipboardManager.current
+ // Generate invite link for QR code
+ val inviteLink = "tssparty://join/$inviteCode"
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Success Icon
+ Surface(
+ modifier = Modifier.size(64.dp),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.primaryContainer
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "会话创建成功",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "分享二维码或邀请码给其他参与方",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // QR Code
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "扫码加入",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Generate QR Code
+ val qrBitmap = remember(inviteCode) {
+ generateQRCodeBitmap(inviteCode, 240)
+ }
+ qrBitmap?.let { bitmap ->
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = "邀请二维码",
+ modifier = Modifier
+ .size(200.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color.White)
+ .padding(8.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "其他参与方可扫描此二维码加入",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Invite Code Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = "邀请码",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Invite code display
+ Surface(
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = inviteCode,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(12.dp),
+ textAlign = TextAlign.Center
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Copy buttons row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ OutlinedButton(
+ onClick = onCopyInviteCode,
+ modifier = Modifier.weight(1f)
+ ) {
+ Icon(
+ Icons.Default.ContentCopy,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("复制邀请码")
+ }
+
+ OutlinedButton(
+ onClick = {
+ clipboardManager.setText(AnnotatedString(inviteLink))
+ },
+ modifier = Modifier.weight(1f)
+ ) {
+ Icon(
+ Icons.Default.Link,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("复制链接")
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = onEnterSession,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("进入会话")
+ }
+ }
+}
+
+/**
+ * Generate QR code bitmap
+ */
+private fun generateQRCodeBitmap(content: String, size: Int): Bitmap? {
+ return try {
+ val writer = QRCodeWriter()
+ val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size)
+ val width = bitMatrix.width
+ val height = bitMatrix.height
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
+ for (x in 0 until width) {
+ for (y in 0 until height) {
+ bitmap.setPixel(x, y, if (bitMatrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE)
+ }
+ }
+ bitmap
+ } catch (e: Exception) {
+ null
+ }
+}
+
+@Composable
+private fun SessionScreen(
+ walletName: String,
+ sessionId: String,
+ sessionStatus: SessionStatus,
+ participants: List,
+ thresholdT: Int,
+ thresholdN: Int,
+ currentRound: Int,
+ totalRounds: Int,
+ publicKey: String?,
+ inviteCode: String?,
+ onCopyInviteCode: () -> Unit,
+ onBackToHome: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp)
+ ) {
+ // Header
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column {
+ Text(
+ text = walletName.ifEmpty { "共管钱包" },
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = sessionId.take(16) + "...",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ // Status Badge
+ Surface(
+ color = when (sessionStatus) {
+ SessionStatus.WAITING -> MaterialTheme.colorScheme.tertiaryContainer
+ SessionStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primaryContainer
+ SessionStatus.COMPLETED -> MaterialTheme.colorScheme.primaryContainer
+ SessionStatus.FAILED -> MaterialTheme.colorScheme.errorContainer
+ },
+ shape = MaterialTheme.shapes.small
+ ) {
+ Text(
+ text = when (sessionStatus) {
+ SessionStatus.WAITING -> "等待参与方"
+ SessionStatus.IN_PROGRESS -> "密钥生成中"
+ SessionStatus.COMPLETED -> "已完成"
+ SessionStatus.FAILED -> "失败"
+ },
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = when (sessionStatus) {
+ SessionStatus.WAITING -> MaterialTheme.colorScheme.onTertiaryContainer
+ SessionStatus.IN_PROGRESS -> MaterialTheme.colorScheme.onPrimaryContainer
+ SessionStatus.COMPLETED -> MaterialTheme.colorScheme.onPrimaryContainer
+ SessionStatus.FAILED -> MaterialTheme.colorScheme.onErrorContainer
+ }
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Invite Code with QR (if waiting)
+ if (sessionStatus == SessionStatus.WAITING && inviteCode != null) {
+ // QR Code Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "扫码加入",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Generate QR Code
+ val qrBitmap = remember(inviteCode) {
+ generateQRCodeBitmap(inviteCode, 200)
+ }
+ qrBitmap?.let { bitmap ->
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = "邀请二维码",
+ modifier = Modifier
+ .size(160.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color.White)
+ .padding(8.dp)
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Invite Code Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = "邀请码",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = inviteCode,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.weight(1f)
+ )
+ OutlinedButton(onClick = onCopyInviteCode) {
+ Icon(
+ Icons.Default.ContentCopy,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("复制")
+ }
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ // Progress Bar (if in progress)
+ if (sessionStatus == SessionStatus.IN_PROGRESS) {
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "密钥生成进度",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = "$currentRound / $totalRounds",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ LinearProgressIndicator(
+ progress = currentRound.toFloat() / totalRounds,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ // Participants List
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = "参与方 (${participants.size} / $thresholdN)",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Show joined participants
+ participants.forEachIndexed { index, name ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = "#${index + 1}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = name,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ Text(
+ text = if (sessionStatus == SessionStatus.COMPLETED) "✓" else "⏳",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+
+ // Show empty slots
+ for (i in participants.size until thresholdN) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = "#${i + 1}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "等待加入...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ Text(
+ text = "⏳",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Threshold Info
+ Surface(
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ shape = MaterialTheme.shapes.small
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "$thresholdT-of-$thresholdN",
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "需要 $thresholdT 个参与方共同签名",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Completion Result
+ if (sessionStatus == SessionStatus.COMPLETED && publicKey != null) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = "钱包公钥",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = publicKey,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = "密钥份额已安全保存到本地",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ // Failure Message
+ if (sessionStatus == SessionStatus.FAILED) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "密钥生成失败,请重试",
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Footer Button
+ Button(
+ onClick = onBackToHome,
+ modifier = Modifier.fillMaxWidth(),
+ colors = if (sessionStatus == SessionStatus.COMPLETED || sessionStatus == SessionStatus.FAILED) {
+ ButtonDefaults.buttonColors()
+ } else {
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ ) {
+ Text("返回首页")
+ }
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/HomeScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/HomeScreen.kt
new file mode 100644
index 00000000..29f3957b
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/HomeScreen.kt
@@ -0,0 +1,211 @@
+package com.durian.tssparty.presentation.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.durian.tssparty.domain.model.ShareRecord
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HomeScreen(
+ shares: List,
+ isConnected: Boolean,
+ onNavigateToJoin: () -> Unit,
+ onNavigateToSign: (Long) -> Unit,
+ onNavigateToSettings: () -> Unit,
+ onDeleteShare: (Long) -> Unit
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("TSS Party") },
+ actions = {
+ // Connection status indicator
+ Icon(
+ imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
+ contentDescription = if (isConnected) "Connected" else "Disconnected",
+ tint = if (isConnected) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ IconButton(onClick = onNavigateToSettings) {
+ Icon(Icons.Default.Settings, contentDescription = "Settings")
+ }
+ }
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(onClick = onNavigateToJoin) {
+ Icon(Icons.Default.Add, contentDescription = "Join Session")
+ }
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(16.dp)
+ ) {
+ Text(
+ text = "My Wallets",
+ style = MaterialTheme.typography.headlineSmall
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (shares.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(
+ imageVector = Icons.Default.AccountBalanceWallet,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.outline
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "No wallets yet",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.outline
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Tap + to join a keygen session",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ } else {
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(shares) { share ->
+ WalletCard(
+ share = share,
+ onSign = { onNavigateToSign(share.id) },
+ onDelete = { onDeleteShare(share.id) }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun WalletCard(
+ share: ShareRecord,
+ onSign: () -> Unit,
+ onDelete: () -> Unit
+) {
+ var showDeleteDialog by remember { mutableStateOf(false) }
+
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Address",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ Text(
+ text = share.address,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "${share.thresholdT}-of-${share.thresholdN}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ Text(
+ text = "Party #${share.partyIndex}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ TextButton(onClick = { showDeleteDialog = true }) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("Delete")
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Button(onClick = onSign) {
+ Icon(
+ Icons.Default.Edit,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("Sign")
+ }
+ }
+ }
+ }
+
+ if (showDeleteDialog) {
+ AlertDialog(
+ onDismissRequest = { showDeleteDialog = false },
+ title = { Text("Delete Wallet") },
+ text = { Text("Are you sure you want to delete this wallet? This action cannot be undone.") },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onDelete()
+ showDeleteDialog = false
+ }
+ ) {
+ Text("Delete", color = MaterialTheme.colorScheme.error)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteDialog = false }) {
+ Text("Cancel")
+ }
+ }
+ )
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinKeygenScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinKeygenScreen.kt
new file mode 100644
index 00000000..ecdaefa2
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinKeygenScreen.kt
@@ -0,0 +1,727 @@
+package com.durian.tssparty.presentation.screens
+
+import android.app.Activity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.durian.tssparty.domain.model.SessionStatus
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
+
+/**
+ * Session info returned from validateInviteCode API
+ * Matches service-party-app SessionInfo type
+ */
+data class JoinSessionInfo(
+ val sessionId: String,
+ val walletName: String,
+ val thresholdT: Int,
+ val thresholdN: Int,
+ val initiator: String,
+ val currentParticipants: Int,
+ val totalParticipants: Int
+)
+
+/**
+ * JoinKeygen screen matching service-party-app/src/renderer/src/pages/Join.tsx
+ * Simplified flow without password: input → confirm → joining
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun JoinKeygenScreen(
+ sessionStatus: SessionStatus,
+ isLoading: Boolean,
+ error: String?,
+ sessionInfo: JoinSessionInfo? = null,
+ participants: List = emptyList(),
+ currentRound: Int = 0,
+ totalRounds: Int = 9,
+ publicKey: String? = null,
+ onValidateInviteCode: (inviteCode: String) -> Unit,
+ onJoinKeygen: (inviteCode: String, password: String) -> Unit,
+ onCancel: () -> Unit,
+ onBackToHome: () -> Unit = {}
+) {
+ var inviteCode by remember { mutableStateOf("") }
+ var validationError by remember { mutableStateOf(null) }
+
+ // 3-step flow: input → confirm → joining
+ var step by remember { mutableStateOf("input") }
+ var autoJoinAttempted by remember { mutableStateOf(false) }
+
+ // Handle session info received (validation success)
+ LaunchedEffect(sessionInfo) {
+ if (sessionInfo != null && step == "input") {
+ step = "confirm"
+ }
+ }
+
+ // Auto-join when we have session info (password is empty string)
+ LaunchedEffect(step, sessionInfo, autoJoinAttempted, isLoading) {
+ if (step == "confirm" && sessionInfo != null && !autoJoinAttempted && !isLoading && error == null) {
+ autoJoinAttempted = true
+ step = "joining"
+ onJoinKeygen(inviteCode, "") // Empty password
+ }
+ }
+
+ // Handle session status changes
+ LaunchedEffect(sessionStatus) {
+ when (sessionStatus) {
+ SessionStatus.IN_PROGRESS -> {
+ step = "progress"
+ }
+ SessionStatus.COMPLETED -> {
+ step = "completed"
+ }
+ SessionStatus.FAILED -> {
+ if (step == "joining") {
+ step = "confirm"
+ }
+ }
+ else -> {}
+ }
+ }
+
+ // Reset auto-join on error
+ LaunchedEffect(error) {
+ if (error != null && step == "joining") {
+ step = "confirm"
+ autoJoinAttempted = false
+ }
+ }
+
+ when (step) {
+ "input" -> InputScreen(
+ inviteCode = inviteCode,
+ isLoading = isLoading,
+ error = error,
+ validationError = validationError,
+ onInviteCodeChange = { inviteCode = it },
+ onValidateCode = {
+ when {
+ inviteCode.isBlank() -> validationError = "请输入邀请码"
+ else -> {
+ validationError = null
+ onValidateInviteCode(inviteCode)
+ }
+ }
+ },
+ onCancel = onCancel
+ )
+ "confirm" -> ConfirmScreen(
+ sessionInfo = sessionInfo,
+ isLoading = isLoading,
+ error = error,
+ onBack = {
+ step = "input"
+ autoJoinAttempted = false
+ },
+ onRetry = {
+ autoJoinAttempted = false
+ }
+ )
+ "joining" -> JoiningScreen()
+ "progress" -> KeygenProgressScreen(
+ sessionStatus = sessionStatus,
+ participants = participants,
+ currentRound = currentRound,
+ totalRounds = totalRounds,
+ onCancel = onCancel
+ )
+ "completed" -> KeygenCompletedScreen(
+ publicKey = publicKey,
+ onBackToHome = onBackToHome
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun InputScreen(
+ inviteCode: String,
+ isLoading: Boolean,
+ error: String?,
+ validationError: String?,
+ onInviteCodeChange: (String) -> Unit,
+ onValidateCode: () -> Unit,
+ onCancel: () -> Unit
+) {
+ val context = LocalContext.current
+
+ // QR Scanner launcher
+ val scanLauncher = rememberLauncherForActivityResult(
+ contract = ScanContract()
+ ) { result ->
+ if (result.contents != null) {
+ // Parse the scanned content (could be invite code or deep link)
+ val scannedContent = result.contents
+ val extractedCode = if (scannedContent.startsWith("tssparty://join/")) {
+ scannedContent.removePrefix("tssparty://join/")
+ } else {
+ scannedContent
+ }
+ onInviteCodeChange(extractedCode)
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp)
+ ) {
+ // Header
+ Text(
+ text = "加入共管钱包",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "扫描二维码或输入邀请码加入多方钱包创建会话",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Scan QR Button
+ Card(
+ onClick = {
+ val options = ScanOptions().apply {
+ setDesiredBarcodeFormats(ScanOptions.QR_CODE)
+ setPrompt("扫描邀请二维码")
+ setCameraId(0)
+ setBeepEnabled(true)
+ setBarcodeImageEnabled(false)
+ setOrientationLocked(true)
+ }
+ scanLauncher.launch(options)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.QrCodeScanner,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = "扫描二维码",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Divider with "或"
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Divider(modifier = Modifier.weight(1f))
+ Text(
+ text = " 或 ",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Divider(modifier = Modifier.weight(1f))
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Invite Code Input
+ OutlinedTextField(
+ value = inviteCode,
+ onValueChange = onInviteCodeChange,
+ label = { Text("邀请码") },
+ placeholder = { Text("粘贴邀请码") },
+ leadingIcon = {
+ Icon(Icons.Default.Key, contentDescription = null)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !isLoading
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Info card
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ Icon(
+ Icons.Default.Info,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "请向会话发起者获取邀请二维码或邀请码",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Error display
+ error?.let {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ validationError?.let {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onCancel,
+ modifier = Modifier.weight(1f),
+ enabled = !isLoading
+ ) {
+ Text("取消")
+ }
+
+ Button(
+ onClick = onValidateCode,
+ modifier = Modifier.weight(1f),
+ enabled = !isLoading && inviteCode.isNotBlank()
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("验证中...")
+ } else {
+ Text("加入会话")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConfirmScreen(
+ sessionInfo: JoinSessionInfo?,
+ isLoading: Boolean,
+ error: String?,
+ onBack: () -> Unit,
+ onRetry: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Header
+ Text(
+ text = "确认会话信息",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Session info card
+ if (sessionInfo != null) {
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ InfoRow("钱包名称", sessionInfo.walletName)
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+ InfoRow("阈值设置", "${sessionInfo.thresholdT}-of-${sessionInfo.thresholdN}")
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+ InfoRow("发起者", sessionInfo.initiator)
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+ InfoRow("当前参与者", "${sessionInfo.currentParticipants} / ${sessionInfo.totalParticipants}")
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Error or auto-joining state
+ if (error != null) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = error,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onBack,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("返回")
+ }
+ Button(
+ onClick = onRetry,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("重试")
+ }
+ }
+ } else {
+ // Auto-joining state
+ CircularProgressIndicator(modifier = Modifier.size(48.dp))
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "正在自动加入会话...",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ }
+}
+
+@Composable
+private fun JoiningScreen() {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(80.dp),
+ strokeWidth = 6.dp
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Text(
+ text = "正在加入会话...",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "请稍候,正在连接到其他参与者",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+private fun KeygenProgressScreen(
+ sessionStatus: SessionStatus,
+ participants: List,
+ currentRound: Int,
+ totalRounds: Int,
+ onCancel: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Header
+ Text(
+ text = "密钥生成中",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "请保持应用在前台,直到密钥生成完成",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Progress indicator
+ CircularProgressIndicator(
+ modifier = Modifier.size(80.dp),
+ strokeWidth = 6.dp
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Progress card
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "协议进度",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = "$currentRound / $totalRounds",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ LinearProgressIndicator(
+ progress = if (totalRounds > 0) currentRound.toFloat() / totalRounds else 0f,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Participants card
+ if (participants.isNotEmpty()) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "参与方 (${participants.size})",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ participants.forEach { participant ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Person,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = participant,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Cancel button
+ OutlinedButton(onClick = onCancel) {
+ Icon(Icons.Default.Cancel, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("取消")
+ }
+ }
+}
+
+@Composable
+private fun KeygenCompletedScreen(
+ publicKey: String?,
+ onBackToHome: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ // Success icon
+ Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(80.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = "密钥生成成功!",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "您的钱包已创建成功,可以在「我的钱包」中查看",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Public key info
+ if (publicKey != null) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "公钥",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "${publicKey.take(20)}...${publicKey.takeLast(20)}",
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Button(
+ onClick = onBackToHome,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(Icons.Default.Home, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("返回首页")
+ }
+ }
+}
+
+@Composable
+private fun InfoRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinScreen.kt
new file mode 100644
index 00000000..2da0c4b6
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinScreen.kt
@@ -0,0 +1,229 @@
+package com.durian.tssparty.presentation.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import com.durian.tssparty.domain.model.SessionStatus
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun JoinScreen(
+ sessionStatus: SessionStatus,
+ isLoading: Boolean,
+ error: String?,
+ onJoinKeygen: (inviteCode: String, password: String) -> Unit,
+ onCancel: () -> Unit,
+ onBack: () -> Unit
+) {
+ var inviteCode by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var confirmPassword by remember { mutableStateOf("") }
+ var showPassword by remember { mutableStateOf(false) }
+ var passwordError by remember { mutableStateOf(null) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Join Keygen") },
+ navigationIcon = {
+ IconButton(onClick = onBack, enabled = !isLoading) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Instructions
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Info,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = "Enter the invite code shared by the session creator and set a password to protect your key share.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+
+ // Invite code input
+ OutlinedTextField(
+ value = inviteCode,
+ onValueChange = { inviteCode = it },
+ label = { Text("Invite Code") },
+ placeholder = { Text("session-id:join-token") },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading,
+ singleLine = true,
+ leadingIcon = {
+ Icon(Icons.Default.QrCode, contentDescription = null)
+ }
+ )
+
+ // Password input
+ OutlinedTextField(
+ value = password,
+ onValueChange = {
+ password = it
+ passwordError = null
+ },
+ label = { Text("Password") },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading,
+ singleLine = true,
+ visualTransformation = if (showPassword) VisualTransformation.None
+ else PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ leadingIcon = {
+ Icon(Icons.Default.Lock, contentDescription = null)
+ },
+ trailingIcon = {
+ IconButton(onClick = { showPassword = !showPassword }) {
+ Icon(
+ if (showPassword) Icons.Default.VisibilityOff
+ else Icons.Default.Visibility,
+ contentDescription = if (showPassword) "Hide password" else "Show password"
+ )
+ }
+ }
+ )
+
+ // Confirm password
+ OutlinedTextField(
+ value = confirmPassword,
+ onValueChange = {
+ confirmPassword = it
+ passwordError = null
+ },
+ label = { Text("Confirm Password") },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading,
+ singleLine = true,
+ visualTransformation = if (showPassword) VisualTransformation.None
+ else PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ leadingIcon = {
+ Icon(Icons.Default.Lock, contentDescription = null)
+ },
+ isError = passwordError != null,
+ supportingText = passwordError?.let { { Text(it) } }
+ )
+
+ // Error message
+ error?.let {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ }
+
+ // Progress indicator
+ if (isLoading) {
+ Card {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator()
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = when (sessionStatus) {
+ SessionStatus.WAITING -> "Waiting for other parties..."
+ SessionStatus.IN_PROGRESS -> "Generating keys..."
+ SessionStatus.COMPLETED -> "Completed!"
+ SessionStatus.FAILED -> "Failed"
+ },
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Action buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ if (isLoading) {
+ OutlinedButton(
+ onClick = onCancel,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("Cancel")
+ }
+ } else {
+ Button(
+ onClick = {
+ if (password != confirmPassword) {
+ passwordError = "Passwords do not match"
+ return@Button
+ }
+ if (password.length < 4) {
+ passwordError = "Password must be at least 4 characters"
+ return@Button
+ }
+ if (inviteCode.isBlank()) {
+ return@Button
+ }
+ onJoinKeygen(inviteCode.trim(), password)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = inviteCode.isNotBlank() && password.isNotBlank() && confirmPassword.isNotBlank()
+ ) {
+ Icon(Icons.Default.PlayArrow, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Join Keygen")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SettingsScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SettingsScreen.kt
new file mode 100644
index 00000000..b2440ea3
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SettingsScreen.kt
@@ -0,0 +1,537 @@
+package com.durian.tssparty.presentation.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.durian.tssparty.domain.model.AppSettings
+import com.durian.tssparty.domain.model.NetworkType
+
+/**
+ * Connection test result
+ */
+data class ConnectionTestResult(
+ val success: Boolean,
+ val message: String,
+ val latency: Long? = null
+)
+
+/**
+ * Settings screen matching service-party-app/src/pages/Settings.tsx
+ * Full implementation with test connection buttons and Account Service URL
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ settings: AppSettings,
+ isConnected: Boolean,
+ messageRouterStatus: ConnectionTestResult? = null,
+ accountServiceStatus: ConnectionTestResult? = null,
+ kavaApiStatus: ConnectionTestResult? = null,
+ onSaveSettings: (AppSettings) -> Unit,
+ onTestMessageRouter: (String) -> Unit = {},
+ onTestAccountService: (String) -> Unit = {},
+ onTestKavaApi: (String) -> Unit = {}
+) {
+ var messageRouterUrl by remember { mutableStateOf(settings.messageRouterUrl) }
+ var accountServiceUrl by remember { mutableStateOf(settings.accountServiceUrl) }
+ var kavaRpcUrl by remember { mutableStateOf(settings.kavaRpcUrl) }
+ var networkType by remember { mutableStateOf(settings.networkType) }
+ var hasChanges by remember { mutableStateOf(false) }
+
+ // Test connection states
+ var isTestingMessageRouter by remember { mutableStateOf(false) }
+ var isTestingAccountService by remember { mutableStateOf(false) }
+ var isTestingKavaApi by remember { mutableStateOf(false) }
+
+ // Local test results (for display)
+ var localMessageRouterResult by remember { mutableStateOf(null) }
+ var localAccountServiceResult by remember { mutableStateOf(null) }
+ var localKavaApiResult by remember { mutableStateOf(null) }
+
+ // Update local results when props change
+ LaunchedEffect(messageRouterStatus) {
+ if (messageRouterStatus != null) {
+ localMessageRouterResult = messageRouterStatus
+ isTestingMessageRouter = false
+ }
+ }
+ LaunchedEffect(accountServiceStatus) {
+ if (accountServiceStatus != null) {
+ localAccountServiceResult = accountServiceStatus
+ isTestingAccountService = false
+ }
+ }
+ LaunchedEffect(kavaApiStatus) {
+ if (kavaApiStatus != null) {
+ localKavaApiResult = kavaApiStatus
+ isTestingKavaApi = false
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp)
+ ) {
+ // Header
+ Text(
+ text = "设置",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "配置应用程序连接和网络设置",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Connection status overview
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = if (isConnected)
+ MaterialTheme.colorScheme.primaryContainer
+ else
+ MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
+ contentDescription = null,
+ tint = if (isConnected)
+ MaterialTheme.colorScheme.onPrimaryContainer
+ else
+ MaterialTheme.colorScheme.onErrorContainer
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Column {
+ Text(
+ text = if (isConnected) "应用就绪" else "连接异常",
+ fontWeight = FontWeight.Medium,
+ color = if (isConnected)
+ MaterialTheme.colorScheme.onPrimaryContainer
+ else
+ MaterialTheme.colorScheme.onErrorContainer
+ )
+ Text(
+ text = if (isConnected) "所有服务正常运行" else "请检查网络设置",
+ style = MaterialTheme.typography.bodySmall,
+ color = if (isConnected)
+ MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
+ else
+ MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Section: Connection Settings
+ Text(
+ text = "连接设置",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Message Router URL
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "消息路由服务",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "TSS 多方计算消息中继服务器",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ value = messageRouterUrl,
+ onValueChange = {
+ messageRouterUrl = it
+ hasChanges = true
+ localMessageRouterResult = null
+ },
+ label = { Text("服务地址") },
+ placeholder = { Text("mpc-grpc.szaiai.com:443") },
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ leadingIcon = {
+ Icon(Icons.Default.Cloud, contentDescription = null)
+ }
+ )
+ Button(
+ onClick = {
+ isTestingMessageRouter = true
+ localMessageRouterResult = null
+ onTestMessageRouter(messageRouterUrl)
+ },
+ enabled = !isTestingMessageRouter && messageRouterUrl.isNotBlank()
+ ) {
+ if (isTestingMessageRouter) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("测试")
+ }
+ }
+ }
+
+ // Test result
+ localMessageRouterResult?.let { result ->
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ if (result.success) Icons.Default.CheckCircle else Icons.Default.Error,
+ contentDescription = null,
+ tint = if (result.success)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = result.message + (result.latency?.let { " (${it}ms)" } ?: ""),
+ style = MaterialTheme.typography.bodySmall,
+ color = if (result.success)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Account Service URL
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "账户服务",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "会话管理和账户 API 服务",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ value = accountServiceUrl,
+ onValueChange = {
+ accountServiceUrl = it
+ hasChanges = true
+ localAccountServiceResult = null
+ },
+ label = { Text("API 地址") },
+ placeholder = { Text("https://rwaapi.szaiai.com") },
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ leadingIcon = {
+ Icon(Icons.Default.Api, contentDescription = null)
+ }
+ )
+ Button(
+ onClick = {
+ isTestingAccountService = true
+ localAccountServiceResult = null
+ onTestAccountService(accountServiceUrl)
+ },
+ enabled = !isTestingAccountService && accountServiceUrl.isNotBlank()
+ ) {
+ if (isTestingAccountService) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("测试")
+ }
+ }
+ }
+
+ // Test result
+ localAccountServiceResult?.let { result ->
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ if (result.success) Icons.Default.CheckCircle else Icons.Default.Error,
+ contentDescription = null,
+ tint = if (result.success)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = result.message + (result.latency?.let { " (${it}ms)" } ?: ""),
+ style = MaterialTheme.typography.bodySmall,
+ color = if (result.success)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Section: Blockchain Network
+ Text(
+ text = "区块链网络",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "选择要连接的 Kava 区块链网络",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ FilterChip(
+ selected = networkType == NetworkType.MAINNET,
+ onClick = {
+ networkType = NetworkType.MAINNET
+ kavaRpcUrl = "https://evm.kava.io"
+ hasChanges = true
+ localKavaApiResult = null
+ },
+ label = { Text("主网 (Kava)") },
+ leadingIcon = if (networkType == NetworkType.MAINNET) {
+ { Icon(Icons.Default.Check, contentDescription = null, Modifier.size(18.dp)) }
+ } else null
+ )
+ FilterChip(
+ selected = networkType == NetworkType.TESTNET,
+ onClick = {
+ networkType = NetworkType.TESTNET
+ kavaRpcUrl = "https://evm.testnet.kava.io"
+ hasChanges = true
+ localKavaApiResult = null
+ },
+ label = { Text("测试网") },
+ leadingIcon = if (networkType == NetworkType.TESTNET) {
+ { Icon(Icons.Default.Check, contentDescription = null, Modifier.size(18.dp)) }
+ } else null
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Kava RPC URL
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "Kava RPC 节点",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "区块链交易和查询 API",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ value = kavaRpcUrl,
+ onValueChange = {
+ kavaRpcUrl = it
+ hasChanges = true
+ localKavaApiResult = null
+ },
+ label = { Text("RPC 地址") },
+ placeholder = { Text("https://evm.kava.io") },
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ leadingIcon = {
+ Icon(Icons.Default.Link, contentDescription = null)
+ }
+ )
+ Button(
+ onClick = {
+ isTestingKavaApi = true
+ localKavaApiResult = null
+ onTestKavaApi(kavaRpcUrl)
+ },
+ enabled = !isTestingKavaApi && kavaRpcUrl.isNotBlank()
+ ) {
+ if (isTestingKavaApi) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("测试")
+ }
+ }
+ }
+
+ // Test result
+ localKavaApiResult?.let { result ->
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ if (result.success) Icons.Default.CheckCircle else Icons.Default.Error,
+ contentDescription = null,
+ tint = if (result.success)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = result.message + (result.latency?.let { " (${it}ms)" } ?: ""),
+ style = MaterialTheme.typography.bodySmall,
+ color = if (result.success)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Section: About
+ Text(
+ text = "关于",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ AboutRow("应用名称", "TSS Party")
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+ AboutRow("版本", "1.0.0")
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+ AboutRow("TSS 协议", "GG20")
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+ AboutRow("区块链", "Kava EVM")
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+ AboutRow("项目", "RWADurian MPC System")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Save button
+ Button(
+ onClick = {
+ onSaveSettings(
+ AppSettings(
+ messageRouterUrl = messageRouterUrl,
+ accountServiceUrl = accountServiceUrl,
+ kavaRpcUrl = kavaRpcUrl,
+ networkType = networkType
+ )
+ )
+ hasChanges = false
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ enabled = hasChanges
+ ) {
+ Icon(Icons.Default.Save, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("保存设置")
+ }
+ }
+}
+
+@Composable
+private fun AboutRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SignScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SignScreen.kt
new file mode 100644
index 00000000..bdf9669f
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SignScreen.kt
@@ -0,0 +1,199 @@
+package com.durian.tssparty.presentation.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.durian.tssparty.domain.model.SessionStatus
+import com.durian.tssparty.domain.model.ShareRecord
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SignScreen(
+ share: ShareRecord?,
+ sessionStatus: SessionStatus,
+ isLoading: Boolean,
+ error: String?,
+ onJoinSign: (inviteCode: String, shareId: Long, password: String) -> Unit,
+ onCancel: () -> Unit,
+ onBack: () -> Unit
+) {
+ var inviteCode by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var showPassword by remember { mutableStateOf(false) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Sign Transaction") },
+ navigationIcon = {
+ IconButton(onClick = onBack, enabled = !isLoading) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Wallet info
+ share?.let { s ->
+ Card {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "Signing with wallet",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = s.address,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "${s.thresholdT}-of-${s.thresholdN} • Party #${s.partyIndex}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ }
+
+ // Invite code input
+ OutlinedTextField(
+ value = inviteCode,
+ onValueChange = { inviteCode = it },
+ label = { Text("Sign Session Code") },
+ placeholder = { Text("session-id:join-token") },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading,
+ singleLine = true,
+ leadingIcon = {
+ Icon(Icons.Default.QrCode, contentDescription = null)
+ }
+ )
+
+ // Password input
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Password") },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading,
+ singleLine = true,
+ visualTransformation = if (showPassword) VisualTransformation.None
+ else PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ leadingIcon = {
+ Icon(Icons.Default.Lock, contentDescription = null)
+ },
+ trailingIcon = {
+ IconButton(onClick = { showPassword = !showPassword }) {
+ Icon(
+ if (showPassword) Icons.Default.VisibilityOff
+ else Icons.Default.Visibility,
+ contentDescription = if (showPassword) "Hide password" else "Show password"
+ )
+ }
+ }
+ )
+
+ // Error message
+ error?.let {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ }
+
+ // Progress indicator
+ if (isLoading) {
+ Card {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator()
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = when (sessionStatus) {
+ SessionStatus.WAITING -> "Waiting for other parties..."
+ SessionStatus.IN_PROGRESS -> "Signing in progress..."
+ SessionStatus.COMPLETED -> "Signed successfully!"
+ SessionStatus.FAILED -> "Signing failed"
+ },
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Action buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ if (isLoading) {
+ OutlinedButton(
+ onClick = onCancel,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("Cancel")
+ }
+ } else {
+ Button(
+ onClick = {
+ share?.let {
+ onJoinSign(inviteCode.trim(), it.id, password)
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = share != null && inviteCode.isNotBlank() && password.isNotBlank()
+ ) {
+ Icon(Icons.Default.Edit, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Sign")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/StartupCheckScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/StartupCheckScreen.kt
new file mode 100644
index 00000000..9b08dd45
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/StartupCheckScreen.kt
@@ -0,0 +1,273 @@
+package com.durian.tssparty.presentation.screens
+
+import androidx.compose.animation.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.durian.tssparty.domain.model.AppReadyState
+import com.durian.tssparty.domain.model.AppState
+import com.durian.tssparty.domain.model.ServiceStatus
+
+@Composable
+fun StartupCheckScreen(
+ appState: AppState,
+ onEnterApp: () -> Unit,
+ onRetry: () -> Unit
+) {
+ val canEnter = appState.appReady == AppReadyState.READY || appState.appReady == AppReadyState.ERROR
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ // App Logo/Icon
+ Box(
+ modifier = Modifier
+ .size(100.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = null,
+ modifier = Modifier.size(50.dp),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // App Title
+ Text(
+ text = "TSS Party",
+ style = MaterialTheme.typography.headlineLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+
+ Text(
+ text = "多方安全计算钱包",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(48.dp))
+
+ // Service Check Cards
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = "服务状态",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Database Status
+ ServiceCheckItem(
+ icon = Icons.Default.Storage,
+ title = "本地数据库",
+ status = appState.environment.database,
+ extraInfo = if (appState.walletCount > 0) "${appState.walletCount} 个钱包" else null
+ )
+
+ Divider(modifier = Modifier.padding(vertical = 12.dp))
+
+ // Message Router Status
+ ServiceCheckItem(
+ icon = Icons.Default.Cloud,
+ title = "消息路由服务",
+ status = appState.environment.messageRouter,
+ extraInfo = appState.partyId?.take(8)?.let { "Party: $it..." }
+ )
+
+ Divider(modifier = Modifier.padding(vertical = 12.dp))
+
+ // Kava API Status
+ ServiceCheckItem(
+ icon = Icons.Default.Language,
+ title = "Kava 区块链",
+ status = appState.environment.kavaApi,
+ extraInfo = appState.environment.kavaApi.latency?.let { "${it}ms" }
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Status Message
+ when (appState.appReady) {
+ AppReadyState.INITIALIZING -> {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = "正在检查服务...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ AppReadyState.READY -> {
+ Text(
+ text = "所有服务就绪",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ AppReadyState.ERROR -> {
+ Text(
+ text = appState.appError ?: "部分服务不可用",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Action Buttons
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ if (appState.appReady == AppReadyState.ERROR) {
+ OutlinedButton(
+ onClick = onRetry,
+ modifier = Modifier.weight(1f)
+ ) {
+ Icon(
+ Icons.Default.Refresh,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("重试")
+ }
+ }
+
+ Button(
+ onClick = onEnterApp,
+ enabled = canEnter,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = when (appState.appReady) {
+ AppReadyState.READY -> "进入应用"
+ AppReadyState.ERROR -> "继续使用"
+ else -> "加载中..."
+ }
+ )
+ if (canEnter) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Icon(
+ Icons.Default.ArrowForward,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ServiceCheckItem(
+ icon: ImageVector,
+ title: String,
+ status: ServiceStatus,
+ extraInfo: String? = null
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Icon with status indicator
+ Box {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ // Status dot
+ Box(
+ modifier = Modifier
+ .size(12.dp)
+ .align(Alignment.BottomEnd)
+ .clip(CircleShape)
+ .background(
+ when {
+ status.message.isEmpty() -> Color.Gray
+ status.isOnline -> Color(0xFF4CAF50)
+ else -> Color(0xFFFF5722)
+ }
+ )
+ )
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium
+ )
+ if (status.message.isNotEmpty()) {
+ Text(
+ text = status.message,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (status.isOnline)
+ MaterialTheme.colorScheme.onSurfaceVariant
+ else
+ MaterialTheme.colorScheme.error
+ )
+ }
+ }
+
+ // Extra info (wallet count, latency, etc.)
+ extraInfo?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt
new file mode 100644
index 00000000..354b11a6
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt
@@ -0,0 +1,1061 @@
+package com.durian.tssparty.presentation.screens
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.durian.tssparty.domain.model.NetworkType
+import com.durian.tssparty.domain.model.SessionStatus
+import com.durian.tssparty.domain.model.ShareRecord
+import com.durian.tssparty.util.TransactionUtils
+import java.math.BigInteger
+
+/**
+ * Transfer Screen - matches service-party-app Home.tsx transfer flow exactly
+ *
+ * Flow:
+ * 1. input - Enter recipient address, amount, password
+ * 2. preparing - Preparing transaction (getting nonce, gas, etc.)
+ * 3. confirm - Confirm transaction details with gas fees
+ * 4. signing - TSS multi-party signing in progress
+ * 5. broadcasting - Broadcasting signed transaction
+ * 6. completed - Show transaction hash and explorer link
+ * 7. error - Show error message
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TransferScreen(
+ wallet: ShareRecord,
+ balance: String?,
+ sessionStatus: SessionStatus,
+ participants: List = emptyList(),
+ currentRound: Int = 0,
+ totalRounds: Int = 9,
+ preparedTx: TransactionUtils.PreparedTransaction? = null,
+ signSessionId: String? = null,
+ inviteCode: String? = null,
+ signature: String? = null,
+ txHash: String? = null,
+ isLoading: Boolean = false,
+ error: String? = null,
+ networkType: NetworkType = NetworkType.MAINNET,
+ onPrepareTransaction: (toAddress: String, amount: String) -> Unit,
+ onConfirmTransaction: (password: String) -> Unit,
+ onCopyInviteCode: () -> Unit,
+ onBroadcastTransaction: () -> Unit,
+ onCancel: () -> Unit,
+ onBackToWallets: () -> Unit
+) {
+ var toAddress by remember { mutableStateOf("") }
+ var amount by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var showPassword by remember { mutableStateOf(false) }
+ var validationError by remember { mutableStateOf(null) }
+
+ // Determine current step
+ val step = when {
+ txHash != null -> "completed"
+ signature != null -> "broadcasting"
+ sessionStatus == SessionStatus.IN_PROGRESS -> "signing"
+ signSessionId != null -> "signing"
+ preparedTx != null && !isLoading -> "confirm"
+ isLoading && preparedTx == null -> "preparing"
+ error != null -> "error"
+ else -> "input"
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp)
+ ) {
+ // Top Bar
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onCancel) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "返回")
+ }
+ Text(
+ text = "转账",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.width(48.dp))
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ when (step) {
+ "input" -> TransferInputScreen(
+ wallet = wallet,
+ balance = balance,
+ toAddress = toAddress,
+ onToAddressChange = { toAddress = it },
+ amount = amount,
+ onAmountChange = { amount = it },
+ error = validationError ?: error,
+ onSubmit = {
+ when {
+ toAddress.isBlank() -> validationError = "请输入收款地址"
+ !toAddress.startsWith("0x") || toAddress.length != 42 -> validationError = "地址格式不正确"
+ amount.isBlank() -> validationError = "请输入金额"
+ amount.toDoubleOrNull() == null || amount.toDouble() <= 0 -> validationError = "金额无效"
+ balance != null && amount.toDouble() > balance.toDouble() -> validationError = "余额不足"
+ else -> {
+ validationError = null
+ onPrepareTransaction(toAddress.trim(), amount.trim())
+ }
+ }
+ },
+ onCancel = onCancel
+ )
+
+ "preparing" -> PreparingScreen()
+
+ "confirm" -> TransferConfirmScreen(
+ wallet = wallet,
+ preparedTx = preparedTx!!,
+ toAddress = toAddress,
+ amount = amount,
+ password = password,
+ onPasswordChange = { password = it },
+ showPassword = showPassword,
+ onTogglePassword = { showPassword = !showPassword },
+ error = error,
+ onConfirm = {
+ if (password.isBlank()) {
+ validationError = "请输入密码"
+ } else {
+ validationError = null
+ onConfirmTransaction(password)
+ }
+ },
+ onBack = onCancel
+ )
+
+ "signing" -> SigningScreen(
+ wallet = wallet,
+ sessionId = signSessionId ?: "",
+ inviteCode = inviteCode,
+ sessionStatus = sessionStatus,
+ participants = participants,
+ currentRound = currentRound,
+ totalRounds = totalRounds,
+ onCopyInviteCode = onCopyInviteCode
+ )
+
+ "broadcasting" -> BroadcastingScreen(
+ isLoading = isLoading,
+ onBroadcast = onBroadcastTransaction
+ )
+
+ "completed" -> CompletedScreen(
+ txHash = txHash!!,
+ toAddress = toAddress,
+ amount = amount,
+ networkType = networkType,
+ onBackToWallets = onBackToWallets
+ )
+
+ "error" -> ErrorScreen(
+ error = error ?: "未知错误",
+ onRetry = onCancel,
+ onBack = onBackToWallets
+ )
+ }
+ }
+}
+
+@Composable
+private fun TransferInputScreen(
+ wallet: ShareRecord,
+ balance: String?,
+ toAddress: String,
+ onToAddressChange: (String) -> Unit,
+ amount: String,
+ onAmountChange: (String) -> Unit,
+ error: String?,
+ onSubmit: () -> Unit,
+ onCancel: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ // From wallet info
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "从",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = wallet.address,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "余额: ",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = if (balance != null) "$balance KAVA" else "加载中...",
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Recipient address
+ OutlinedTextField(
+ value = toAddress,
+ onValueChange = onToAddressChange,
+ label = { Text("收款地址") },
+ placeholder = { Text("0x...") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ leadingIcon = {
+ Icon(Icons.Default.Person, contentDescription = null)
+ }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Amount
+ OutlinedTextField(
+ value = amount,
+ onValueChange = onAmountChange,
+ label = { Text("金额 (KAVA)") },
+ placeholder = { Text("0.0") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ leadingIcon = {
+ Icon(Icons.Default.AttachMoney, contentDescription = null)
+ },
+ trailingIcon = {
+ if (balance != null) {
+ TextButton(
+ onClick = { onAmountChange(balance) }
+ ) {
+ Text("全部", style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Info card
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ Icon(
+ Icons.Default.Info,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "转账需要 ${wallet.thresholdT} 个参与者共同签名。确认后将创建签名会话,其他参与者需要加入会话完成签名。",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ }
+
+ // Error display
+ error?.let {
+ Spacer(modifier = Modifier.height(16.dp))
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onCancel,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("取消")
+ }
+ Button(
+ onClick = onSubmit,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("下一步")
+ }
+ }
+ }
+}
+
+@Composable
+private fun PreparingScreen() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ strokeWidth = 4.dp
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "准备交易中...",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "正在获取 Gas 价格和 Nonce",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+@Composable
+private fun TransferConfirmScreen(
+ wallet: ShareRecord,
+ preparedTx: TransactionUtils.PreparedTransaction,
+ toAddress: String,
+ amount: String,
+ password: String,
+ onPasswordChange: (String) -> Unit,
+ showPassword: Boolean,
+ onTogglePassword: () -> Unit,
+ error: String?,
+ onConfirm: () -> Unit,
+ onBack: () -> Unit
+) {
+ val gasFee = TransactionUtils.weiToKava(preparedTx.gasPrice.multiply(preparedTx.gasLimit))
+ val gasGwei = TransactionUtils.weiToGwei(preparedTx.gasPrice)
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = "确认交易",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Transaction summary card
+ Card {
+ Column(modifier = Modifier.padding(16.dp)) {
+ // Amount
+ Text(
+ text = "转账金额",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "$amount KAVA",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Divider()
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // To address
+ InfoRow("收款地址", toAddress.take(10) + "..." + toAddress.takeLast(8))
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Gas info
+ InfoRow("Gas 价格", "$gasGwei Gwei")
+ Spacer(modifier = Modifier.height(4.dp))
+ InfoRow("Gas 限制", preparedTx.gasLimit.toString())
+ Spacer(modifier = Modifier.height(4.dp))
+ InfoRow("预估手续费", "$gasFee KAVA")
+ Spacer(modifier = Modifier.height(4.dp))
+ InfoRow("Nonce", preparedTx.nonce.toString())
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Threshold info
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Group,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "此交易需要 ${wallet.thresholdT} 个参与者共同签名",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Password input
+ OutlinedTextField(
+ value = password,
+ onValueChange = onPasswordChange,
+ label = { Text("钱包密码") },
+ placeholder = { Text("输入密码解锁密钥份额") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
+ leadingIcon = {
+ Icon(Icons.Default.Lock, contentDescription = null)
+ },
+ trailingIcon = {
+ IconButton(onClick = onTogglePassword) {
+ Icon(
+ if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
+ contentDescription = null
+ )
+ }
+ }
+ )
+
+ // Error display
+ error?.let {
+ Spacer(modifier = Modifier.height(16.dp))
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = onBack,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("返回")
+ }
+ Button(
+ onClick = onConfirm,
+ modifier = Modifier.weight(1f)
+ ) {
+ Icon(
+ Icons.Default.Send,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("发起签名")
+ }
+ }
+ }
+}
+
+@Composable
+private fun SigningScreen(
+ wallet: ShareRecord,
+ sessionId: String,
+ inviteCode: String?,
+ sessionStatus: SessionStatus,
+ participants: List,
+ currentRound: Int,
+ totalRounds: Int,
+ onCopyInviteCode: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ // Header with session ID
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "签名会话",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+
+ // Status badge
+ Surface(
+ color = when (sessionStatus) {
+ SessionStatus.WAITING -> MaterialTheme.colorScheme.tertiaryContainer
+ SessionStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primaryContainer
+ SessionStatus.COMPLETED -> Color(0xFF4CAF50)
+ SessionStatus.FAILED -> MaterialTheme.colorScheme.errorContainer
+ },
+ shape = MaterialTheme.shapes.small
+ ) {
+ Text(
+ text = when (sessionStatus) {
+ SessionStatus.WAITING -> "等待参与方"
+ SessionStatus.IN_PROGRESS -> "签名中"
+ SessionStatus.COMPLETED -> "完成"
+ SessionStatus.FAILED -> "失败"
+ },
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelSmall
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "会话 ID: ${sessionId.take(16)}...",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Invite code (if waiting)
+ if (sessionStatus == SessionStatus.WAITING && inviteCode != null) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "邀请码",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = inviteCode,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.weight(1f)
+ )
+ Button(onClick = onCopyInviteCode) {
+ Text("复制")
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "将此邀请码分享给其他签名参与者",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ // Progress bar (if in progress)
+ if (sessionStatus == SessionStatus.IN_PROGRESS) {
+ Card {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "签名进度",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = "$currentRound / $totalRounds",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ LinearProgressIndicator(
+ progress = currentRound.toFloat() / totalRounds,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ // Participants list
+ Card {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "签名参与者 (${participants.size} / ${wallet.thresholdT})",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ participants.forEachIndexed { index, name ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = "#${index + 1}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = name,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ tint = Color(0xFF4CAF50),
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+
+ // Show empty slots
+ for (i in participants.size until wallet.thresholdT) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = "#${i + 1}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "等待加入...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Info text
+ if (sessionStatus == SessionStatus.WAITING) {
+ Text(
+ text = "等待其他参与者加入后将自动开始签名...",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+ } else if (sessionStatus == SessionStatus.IN_PROGRESS) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "正在进行多方签名计算...",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun BroadcastingScreen(
+ isLoading: Boolean,
+ onBroadcast: () -> Unit
+) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Success icon
+ Surface(
+ modifier = Modifier.size(80.dp),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = Color(0xFF4CAF50)
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = null,
+ modifier = Modifier.size(40.dp),
+ tint = Color.White
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = "签名完成!",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "交易已签名,点击下方按钮广播到主网",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Button(
+ onClick = onBroadcast,
+ enabled = !isLoading,
+ modifier = Modifier.fillMaxWidth(0.8f)
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("广播中...")
+ } else {
+ Icon(
+ Icons.Default.Send,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("广播交易")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CompletedScreen(
+ txHash: String,
+ toAddress: String,
+ amount: String,
+ networkType: NetworkType,
+ onBackToWallets: () -> Unit
+) {
+ val context = LocalContext.current
+
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Success icon
+ Surface(
+ modifier = Modifier.size(100.dp),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = Color(0xFF4CAF50)
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(60.dp),
+ tint = Color.White
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = "交易已成功广播!",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "向 ${toAddress.take(10)}...${toAddress.takeLast(8)} 转账 $amount KAVA",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Transaction hash card
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "交易哈希",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = txHash,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // View on explorer button
+ OutlinedButton(
+ onClick = {
+ val baseUrl = if (networkType == NetworkType.TESTNET) {
+ "https://testnet.kavascan.com"
+ } else {
+ "https://kavascan.com"
+ }
+ val url = "$baseUrl/tx/$txHash"
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
+ context.startActivity(intent)
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(
+ Icons.Default.OpenInNew,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("在 KavaScan 查看")
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Button(
+ onClick = onBackToWallets,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("返回钱包列表")
+ }
+ }
+ }
+}
+
+@Composable
+private fun ErrorScreen(
+ error: String,
+ onRetry: () -> Unit,
+ onBack: () -> Unit
+) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Surface(
+ modifier = Modifier.size(80.dp),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.errorContainer
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ modifier = Modifier.size(40.dp),
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = "交易失败",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(onClick = onBack) {
+ Text("返回")
+ }
+ Button(onClick = onRetry) {
+ Text("重试")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun InfoRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt
new file mode 100644
index 00000000..4255531d
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt
@@ -0,0 +1,805 @@
+package com.durian.tssparty.presentation.screens
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import com.durian.tssparty.domain.model.ShareRecord
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.qrcode.QRCodeWriter
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WalletsScreen(
+ shares: List,
+ isConnected: Boolean,
+ balances: Map = emptyMap(),
+ onDeleteShare: (Long) -> Unit,
+ onRefreshBalance: ((String) -> Unit)? = null,
+ onTransfer: ((shareId: Long, toAddress: String, amount: String, password: String) -> Unit)? = null,
+ onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
+ onCreateWallet: (() -> Unit)? = null
+) {
+ var selectedWallet by remember { mutableStateOf(null) }
+ var showTransferDialog by remember { mutableStateOf(false) }
+ var transferWallet by remember { mutableStateOf(null) }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp)
+ ) {
+ // Header with connection status
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "我的钱包",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ // Connection status
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
+ contentDescription = if (isConnected) "已连接" else "未连接",
+ tint = if (isConnected) Color(0xFF4CAF50) else MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = if (isConnected) "已连接" else "离线",
+ style = MaterialTheme.typography.bodySmall,
+ color = if (isConnected) Color(0xFF4CAF50) else MaterialTheme.colorScheme.error
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "共 ${shares.size} 个钱包",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (shares.isEmpty()) {
+ // Empty state
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(
+ imageVector = Icons.Default.AccountBalanceWallet,
+ contentDescription = null,
+ modifier = Modifier.size(80.dp),
+ tint = MaterialTheme.colorScheme.outline
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "暂无钱包",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.outline
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "使用「创建钱包」发起新钱包",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ Text(
+ text = "或使用「加入创建」参与他人的会话",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ } else {
+ // Wallet list
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ contentPadding = PaddingValues(bottom = 80.dp) // Space for FAB
+ ) {
+ items(shares) { share ->
+ WalletItemCard(
+ share = share,
+ balance = balances[share.address],
+ onViewDetails = { selectedWallet = share },
+ onTransfer = {
+ transferWallet = share
+ showTransferDialog = true
+ },
+ onDelete = { onDeleteShare(share.id) }
+ )
+ }
+ }
+ }
+ }
+
+ // Floating Action Button for creating wallet
+ if (onCreateWallet != null) {
+ FloatingActionButton(
+ onClick = onCreateWallet,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(16.dp),
+ containerColor = MaterialTheme.colorScheme.primary
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "创建钱包",
+ tint = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ }
+
+ // Wallet detail dialog
+ selectedWallet?.let { wallet ->
+ WalletDetailDialog(
+ wallet = wallet,
+ onDismiss = { selectedWallet = null },
+ onTransfer = {
+ selectedWallet = null
+ transferWallet = wallet
+ showTransferDialog = true
+ },
+ onExport = onExportBackup?.let { export ->
+ { password -> export(wallet.id, password) }
+ }
+ )
+ }
+
+ // Transfer dialog
+ if (showTransferDialog && transferWallet != null) {
+ TransferDialog(
+ wallet = transferWallet!!,
+ onDismiss = {
+ showTransferDialog = false
+ transferWallet = null
+ },
+ onConfirm = { toAddress, amount, password ->
+ onTransfer?.invoke(transferWallet!!.id, toAddress, amount, password)
+ showTransferDialog = false
+ transferWallet = null
+ }
+ )
+ }
+}
+
+@Composable
+private fun WalletItemCard(
+ share: ShareRecord,
+ balance: String? = null,
+ onViewDetails: () -> Unit,
+ onTransfer: () -> Unit,
+ onDelete: () -> Unit
+) {
+ var showDeleteDialog by remember { mutableStateOf(false) }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onViewDetails() }
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ // Header with threshold badge
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Threshold badge
+ Surface(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shape = RoundedCornerShape(4.dp)
+ ) {
+ Text(
+ text = "${share.thresholdT}-of-${share.thresholdN}",
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ // Party index
+ Text(
+ text = "参与者 #${share.partyIndex}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Address
+ Text(
+ text = "地址",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ Text(
+ text = share.address,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ fontFamily = FontFamily.Monospace
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Balance display
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.AccountBalance,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ if (balance != null) {
+ Text(
+ text = "$balance KAVA",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Medium
+ )
+ } else {
+ // Loading state
+ Text(
+ text = "加载中...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Divider()
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Actions
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ TextButton(onClick = onViewDetails) {
+ Icon(
+ Icons.Default.QrCode,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("详情")
+ }
+
+ TextButton(onClick = onTransfer) {
+ Icon(
+ Icons.Default.Send,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("转账")
+ }
+
+ TextButton(
+ onClick = { showDeleteDialog = true },
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("删除")
+ }
+ }
+ }
+ }
+
+ // Delete confirmation dialog
+ if (showDeleteDialog) {
+ AlertDialog(
+ onDismissRequest = { showDeleteDialog = false },
+ icon = {
+ Icon(
+ Icons.Default.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ },
+ title = { Text("删除钱包") },
+ text = {
+ Text("确定要删除这个钱包吗?此操作无法撤销,删除后您将无法使用此密钥份额参与签名。")
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onDelete()
+ showDeleteDialog = false
+ },
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text("删除")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteDialog = false }) {
+ Text("取消")
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun WalletDetailDialog(
+ wallet: ShareRecord,
+ onDismiss: () -> Unit,
+ onTransfer: () -> Unit,
+ onExport: ((String) -> Unit)?
+) {
+ val clipboardManager = LocalClipboardManager.current
+ var showExportDialog by remember { mutableStateOf(false) }
+
+ Dialog(onDismissRequest = onDismiss) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // QR Code
+ val qrBitmap = remember(wallet.address) {
+ generateQRCode(wallet.address, 240)
+ }
+ qrBitmap?.let { bitmap ->
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = "QR Code",
+ modifier = Modifier
+ .size(200.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color.White)
+ .padding(8.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Threshold badge
+ Surface(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Text(
+ text = "${wallet.thresholdT}-of-${wallet.thresholdN} 多签钱包",
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Address
+ Text(
+ text = "Kava EVM 地址",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = wallet.address,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Copy button
+ OutlinedButton(
+ onClick = {
+ clipboardManager.setText(AnnotatedString(wallet.address))
+ }
+ ) {
+ Icon(
+ Icons.Default.ContentCopy,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("复制地址")
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Divider()
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Info rows
+ InfoRow("门限设置", "${wallet.thresholdT}-of-${wallet.thresholdN}")
+ InfoRow("您的序号", "#${wallet.partyIndex}")
+ InfoRow("会话ID", wallet.sessionId.take(16) + "...")
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Action buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ OutlinedButton(
+ onClick = onTransfer,
+ modifier = Modifier.weight(1f)
+ ) {
+ Icon(
+ Icons.Default.Send,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("转账")
+ }
+
+ if (onExport != null) {
+ OutlinedButton(
+ onClick = { showExportDialog = true },
+ modifier = Modifier.weight(1f)
+ ) {
+ Icon(
+ Icons.Default.Download,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("导出")
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(onClick = onDismiss) {
+ Text("关闭")
+ }
+ }
+ }
+ }
+
+ // Export dialog
+ if (showExportDialog && onExport != null) {
+ ExportBackupDialog(
+ onDismiss = { showExportDialog = false },
+ onConfirm = { password ->
+ onExport(password)
+ showExportDialog = false
+ }
+ )
+ }
+}
+
+@Composable
+private fun InfoRow(label: String, value: String) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ }
+}
+
+@Composable
+private fun TransferDialog(
+ wallet: ShareRecord,
+ onDismiss: () -> Unit,
+ onConfirm: (toAddress: String, amount: String, password: String) -> Unit
+) {
+ var toAddress by remember { mutableStateOf("") }
+ var amount by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var showPassword by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(null) }
+
+ Dialog(onDismissRequest = onDismiss) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp)
+ ) {
+ Text(
+ text = "转账",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "从 ${wallet.address.take(10)}...${wallet.address.takeLast(8)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Recipient address
+ OutlinedTextField(
+ value = toAddress,
+ onValueChange = { toAddress = it },
+ label = { Text("收款地址") },
+ placeholder = { Text("0x...") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ leadingIcon = {
+ Icon(Icons.Default.Person, contentDescription = null)
+ }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Amount
+ OutlinedTextField(
+ value = amount,
+ onValueChange = { amount = it },
+ label = { Text("金额 (KAVA)") },
+ placeholder = { Text("0.0") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ leadingIcon = {
+ Icon(Icons.Default.AttachMoney, contentDescription = null)
+ }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Password
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("钱包密码") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
+ leadingIcon = {
+ Icon(Icons.Default.Lock, contentDescription = null)
+ },
+ trailingIcon = {
+ IconButton(onClick = { showPassword = !showPassword }) {
+ Icon(
+ if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
+ contentDescription = null
+ )
+ }
+ }
+ )
+
+ error?.let {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Info card
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ Icon(
+ Icons.Default.Info,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "转账需要 ${wallet.thresholdT} 个参与者共同签名。确认后将创建签名会话。",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ OutlinedButton(
+ onClick = onDismiss,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("取消")
+ }
+
+ Button(
+ onClick = {
+ when {
+ toAddress.isBlank() -> error = "请输入收款地址"
+ !toAddress.startsWith("0x") || toAddress.length != 42 -> error = "地址格式不正确"
+ amount.isBlank() -> error = "请输入金额"
+ amount.toDoubleOrNull() == null || amount.toDouble() <= 0 -> error = "金额无效"
+ password.isBlank() -> error = "请输入密码"
+ else -> {
+ error = null
+ onConfirm(toAddress, amount, password)
+ }
+ }
+ },
+ modifier = Modifier.weight(1f)
+ ) {
+ Icon(
+ Icons.Default.Send,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("确认转账")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExportBackupDialog(
+ onDismiss: () -> Unit,
+ onConfirm: (password: String) -> Unit
+) {
+ var password by remember { mutableStateOf("") }
+ var showPassword by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ icon = {
+ Icon(Icons.Default.Download, contentDescription = null)
+ },
+ title = { Text("导出备份") },
+ text = {
+ Column {
+ Text("导出加密备份文件,请妥善保管。")
+ Spacer(modifier = Modifier.height(16.dp))
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("密码") },
+ singleLine = true,
+ visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
+ trailingIcon = {
+ IconButton(onClick = { showPassword = !showPassword }) {
+ Icon(
+ if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
+ contentDescription = null
+ )
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(password) },
+ enabled = password.isNotBlank()
+ ) {
+ Text("导出")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("取消")
+ }
+ }
+ )
+}
+
+/**
+ * Generate QR code bitmap
+ */
+private fun generateQRCode(content: String, size: Int): Bitmap? {
+ return try {
+ val writer = QRCodeWriter()
+ val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size)
+ val width = bitMatrix.width
+ val height = bitMatrix.height
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
+ for (x in 0 until width) {
+ for (y in 0 until height) {
+ bitmap.setPixel(x, y, if (bitMatrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE)
+ }
+ }
+ bitmap
+ } catch (e: Exception) {
+ null
+ }
+}
diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt
new file mode 100644
index 00000000..43e74c7e
--- /dev/null
+++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt
@@ -0,0 +1,855 @@
+package com.durian.tssparty.presentation.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.durian.tssparty.data.repository.TssRepository
+import com.durian.tssparty.domain.model.*
+import com.durian.tssparty.util.TransactionUtils
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val repository: TssRepository
+) : ViewModel() {
+
+ // App State (similar to Zustand store)
+ private val _appState = MutableStateFlow(AppState())
+ val appState: StateFlow = _appState.asStateFlow()
+
+ // UI State
+ private val _uiState = MutableStateFlow(MainUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ // Settings
+ private val _settings = MutableStateFlow(AppSettings())
+ val settings: StateFlow = _settings.asStateFlow()
+
+ // Share records
+ val shares: StateFlow> = repository.getAllShares()
+ .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
+
+ // Session status from repository
+ val sessionStatus: StateFlow = repository.sessionStatus
+
+ // Created session invite code
+ private val _createdInviteCode = MutableStateFlow(null)
+ val createdInviteCode: StateFlow = _createdInviteCode.asStateFlow()
+
+ init {
+ // Start initialization on app launch
+ checkAllServices()
+ }
+
+ /**
+ * Check all services for startup
+ */
+ fun checkAllServices() {
+ viewModelScope.launch {
+ _appState.update { it.copy(appReady = AppReadyState.INITIALIZING) }
+
+ var hasError = false
+ var errorMessage: String? = null
+
+ // 1. Check Database
+ try {
+ val walletCount = repository.getShareCount()
+ _appState.update {
+ it.copy(
+ walletCount = walletCount,
+ environment = it.environment.copy(
+ database = ServiceStatus(
+ isOnline = true,
+ message = "正常"
+ )
+ )
+ )
+ }
+ } catch (e: Exception) {
+ hasError = true
+ errorMessage = "数据库错误: ${e.message}"
+ _appState.update {
+ it.copy(
+ environment = it.environment.copy(
+ database = ServiceStatus(
+ isOnline = false,
+ message = e.message ?: "错误"
+ )
+ )
+ )
+ }
+ }
+
+ // 2. Check Message Router
+ try {
+ val serverUrl = _settings.value.messageRouterUrl
+ val parts = serverUrl.split(":")
+ val host = parts[0]
+ val port = parts.getOrNull(1)?.toIntOrNull() ?: 50051
+
+ repository.connect(host, port)
+ val partyId = repository.registerParty()
+
+ _appState.update {
+ it.copy(
+ partyId = partyId,
+ environment = it.environment.copy(
+ messageRouter = ServiceStatus(
+ isOnline = true,
+ message = "已连接"
+ )
+ )
+ )
+ }
+ _uiState.update { it.copy(isConnected = true) }
+ } catch (e: Exception) {
+ hasError = true
+ if (errorMessage == null) errorMessage = "消息路由: ${e.message}"
+ _appState.update {
+ it.copy(
+ environment = it.environment.copy(
+ messageRouter = ServiceStatus(
+ isOnline = false,
+ message = e.message ?: "连接失败"
+ )
+ )
+ )
+ }
+ _uiState.update { it.copy(isConnected = false) }
+ }
+
+ // 3. Check Kava API
+ try {
+ val startTime = System.currentTimeMillis()
+ val isHealthy = repository.checkKavaHealth()
+ val latency = System.currentTimeMillis() - startTime
+
+ _appState.update {
+ it.copy(
+ environment = it.environment.copy(
+ kavaApi = ServiceStatus(
+ isOnline = isHealthy,
+ message = if (isHealthy) "正常" else "不可用",
+ latency = latency
+ )
+ )
+ )
+ }
+ } catch (e: Exception) {
+ // Kava API is not critical
+ _appState.update {
+ it.copy(
+ environment = it.environment.copy(
+ kavaApi = ServiceStatus(
+ isOnline = false,
+ message = e.message ?: "错误"
+ )
+ )
+ )
+ }
+ }
+
+ // Small delay for visual feedback
+ delay(1000)
+
+ // Update final state
+ _appState.update {
+ it.copy(
+ appReady = if (hasError) AppReadyState.ERROR else AppReadyState.READY,
+ appError = errorMessage
+ )
+ }
+ }
+ }
+
+ /**
+ * Connect to Message Router server
+ */
+ fun connectToServer(serverUrl: String) {
+ viewModelScope.launch {
+ try {
+ val parts = serverUrl.split(":")
+ val host = parts[0]
+ val port = parts.getOrNull(1)?.toIntOrNull() ?: 50051
+
+ repository.connect(host, port)
+ repository.registerParty()
+
+ _uiState.update { it.copy(isConnected = true, error = null) }
+ _appState.update {
+ it.copy(
+ environment = it.environment.copy(
+ messageRouter = ServiceStatus(isOnline = true, message = "已连接")
+ )
+ )
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(isConnected = false, error = e.message) }
+ _appState.update {
+ it.copy(
+ environment = it.environment.copy(
+ messageRouter = ServiceStatus(isOnline = false, message = e.message ?: "失败")
+ )
+ )
+ }
+ }
+ }
+ }
+
+ // Current session data for CreateWalletScreen
+ private val _currentSessionId = MutableStateFlow(null)
+ val currentSessionId: StateFlow = _currentSessionId.asStateFlow()
+
+ private val _sessionParticipants = MutableStateFlow>(emptyList())
+ val sessionParticipants: StateFlow> = _sessionParticipants.asStateFlow()
+
+ private val _currentRound = MutableStateFlow(0)
+ val currentRound: StateFlow = _currentRound.asStateFlow()
+
+ private val _publicKey = MutableStateFlow(null)
+ val publicKey: StateFlow = _publicKey.asStateFlow()
+
+ /**
+ * Create a new keygen session (initiator)
+ * Note: password is not needed for creating session in service-party-app
+ */
+ fun createKeygenSession(walletName: String, thresholdT: Int, thresholdN: Int, participantName: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+
+ val result = repository.createKeygenSession(walletName, thresholdT, thresholdN, participantName)
+
+ result.fold(
+ onSuccess = { inviteCode ->
+ _createdInviteCode.value = inviteCode
+ // Parse sessionId from invite code (format: sessionId:joinToken)
+ val sessionId = inviteCode.split(":").firstOrNull()
+ _currentSessionId.value = sessionId
+ // Add self as first participant
+ _sessionParticipants.value = listOf(participantName)
+ _uiState.update { it.copy(isLoading = false) }
+ },
+ onFailure = { e ->
+ _uiState.update { it.copy(isLoading = false, error = e.message) }
+ }
+ )
+ }
+ }
+
+ /**
+ * Enter session (transition from created to session screen)
+ */
+ fun enterSession() {
+ // This triggers the session screen to show
+ // The session status will change to IN_PROGRESS once keygen starts
+ }
+
+ /**
+ * Reset session state (back to home)
+ */
+ fun resetSessionState() {
+ _currentSessionId.value = null
+ _sessionParticipants.value = emptyList()
+ _currentRound.value = 0
+ _publicKey.value = null
+ _createdInviteCode.value = null
+ }
+
+ // ========== Join Keygen State ==========
+
+ private val _joinSessionInfo = MutableStateFlow(null)
+ val joinSessionInfo: StateFlow = _joinSessionInfo.asStateFlow()
+
+ private val _joinKeygenParticipants = MutableStateFlow>(emptyList())
+ val joinKeygenParticipants: StateFlow> = _joinKeygenParticipants.asStateFlow()
+
+ private val _joinKeygenRound = MutableStateFlow(0)
+ val joinKeygenRound: StateFlow = _joinKeygenRound.asStateFlow()
+
+ private val _joinKeygenPublicKey = MutableStateFlow(null)
+ val joinKeygenPublicKey: StateFlow = _joinKeygenPublicKey.asStateFlow()
+
+ // Store invite code and password for auto-join
+ private var pendingInviteCode: String = ""
+ private var pendingPassword: String = ""
+
+ /**
+ * Validate invite code and get session info
+ */
+ fun validateInviteCode(inviteCode: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+ pendingInviteCode = inviteCode
+
+ val result = repository.validateInviteCode(inviteCode)
+
+ result.fold(
+ onSuccess = { validateResult ->
+ val info = validateResult.sessionInfo
+ _joinSessionInfo.value = JoinKeygenSessionInfo(
+ sessionId = info.sessionId,
+ walletName = info.walletName,
+ thresholdT = info.thresholdT,
+ thresholdN = info.thresholdN,
+ initiator = info.initiator,
+ currentParticipants = info.currentParticipants,
+ totalParticipants = info.totalParticipants
+ )
+ _uiState.update { it.copy(isLoading = false) }
+ },
+ onFailure = { e ->
+ _uiState.update { it.copy(isLoading = false, error = e.message) }
+ }
+ )
+ }
+ }
+
+ /**
+ * Join a keygen session (called after validateInviteCode)
+ */
+ fun joinKeygen(inviteCode: String, password: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+ pendingPassword = password
+
+ val result = repository.joinKeygenSession(inviteCode, password)
+
+ result.fold(
+ onSuccess = { share ->
+ _joinKeygenPublicKey.value = share.publicKey
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ lastCreatedAddress = share.address,
+ successMessage = "钱包创建成功!"
+ )
+ }
+ // Update wallet count
+ _appState.update { state ->
+ state.copy(walletCount = state.walletCount + 1)
+ }
+ },
+ onFailure = { e ->
+ _uiState.update {
+ it.copy(isLoading = false, error = e.message)
+ }
+ }
+ )
+ }
+ }
+
+ /**
+ * Reset join keygen state
+ */
+ fun resetJoinKeygenState() {
+ _joinSessionInfo.value = null
+ _joinKeygenParticipants.value = emptyList()
+ _joinKeygenRound.value = 0
+ _joinKeygenPublicKey.value = null
+ pendingInviteCode = ""
+ pendingPassword = ""
+ }
+
+ // ========== CoSign (Join Sign) State ==========
+
+ private val _coSignSessionInfo = MutableStateFlow(null)
+ val coSignSessionInfo: StateFlow = _coSignSessionInfo.asStateFlow()
+
+ private val _coSignParticipants = MutableStateFlow>(emptyList())
+ val coSignParticipants: StateFlow> = _coSignParticipants.asStateFlow()
+
+ private val _coSignRound = MutableStateFlow(0)
+ val coSignRound: StateFlow = _coSignRound.asStateFlow()
+
+ private val _coSignSignature = MutableStateFlow(null)
+ val coSignSignature: StateFlow = _coSignSignature.asStateFlow()
+
+ // Store pending CoSign data
+ private var pendingCoSignInviteCode: String = ""
+
+ /**
+ * Validate sign session invite code
+ */
+ fun validateSignInviteCode(inviteCode: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+ pendingCoSignInviteCode = inviteCode
+
+ val result = repository.validateSignInviteCode(inviteCode)
+
+ result.fold(
+ onSuccess = { validateResult ->
+ val info = validateResult.signSessionInfo
+ _coSignSessionInfo.value = CoSignSessionInfo(
+ sessionId = info.sessionId,
+ keygenSessionId = info.keygenSessionId,
+ walletName = info.walletName,
+ messageHash = info.messageHash,
+ thresholdT = info.thresholdT,
+ thresholdN = info.thresholdN,
+ currentParticipants = info.currentParticipants
+ )
+ _uiState.update { it.copy(isLoading = false) }
+ },
+ onFailure = { e ->
+ _uiState.update { it.copy(isLoading = false, error = e.message) }
+ }
+ )
+ }
+ }
+
+ /**
+ * Join a sign session
+ */
+ fun joinSign(inviteCode: String, shareId: Long, password: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+
+ val result = repository.joinSignSession(inviteCode, shareId, password)
+
+ result.fold(
+ onSuccess = { signResult ->
+ _coSignSignature.value = signResult.signature
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ lastSignature = signResult.signature,
+ successMessage = "签名完成!"
+ )
+ }
+ },
+ onFailure = { e ->
+ _uiState.update {
+ it.copy(isLoading = false, error = e.message)
+ }
+ }
+ )
+ }
+ }
+
+ /**
+ * Reset CoSign state
+ */
+ fun resetCoSignState() {
+ _coSignSessionInfo.value = null
+ _coSignParticipants.value = emptyList()
+ _coSignRound.value = 0
+ _coSignSignature.value = null
+ pendingCoSignInviteCode = ""
+ }
+
+ /**
+ * Cancel current session
+ */
+ fun cancelSession() {
+ repository.cancelSession()
+ _createdInviteCode.value = null
+ _uiState.update { it.copy(isLoading = false) }
+ }
+
+ /**
+ * Delete a share
+ */
+ fun deleteShare(id: Long) {
+ viewModelScope.launch {
+ repository.deleteShare(id)
+ // Update wallet count
+ _appState.update { state ->
+ state.copy(walletCount = (state.walletCount - 1).coerceAtLeast(0))
+ }
+ }
+ }
+
+ /**
+ * Update settings
+ */
+ fun updateSettings(newSettings: AppSettings) {
+ _settings.value = newSettings
+ connectToServer(newSettings.messageRouterUrl)
+ // Update account service URL in repository
+ repository.setAccountServiceUrl(newSettings.accountServiceUrl)
+ }
+
+ // ========== Connection Test State ==========
+
+ private val _messageRouterTestResult = MutableStateFlow(null)
+ val messageRouterTestResult: StateFlow = _messageRouterTestResult.asStateFlow()
+
+ private val _accountServiceTestResult = MutableStateFlow(null)
+ val accountServiceTestResult: StateFlow = _accountServiceTestResult.asStateFlow()
+
+ private val _kavaApiTestResult = MutableStateFlow(null)
+ val kavaApiTestResult: StateFlow = _kavaApiTestResult.asStateFlow()
+
+ /**
+ * Test Message Router connection
+ */
+ fun testMessageRouter(serverUrl: String) {
+ viewModelScope.launch {
+ _messageRouterTestResult.value = null
+ val result = repository.testMessageRouter(serverUrl)
+ result.fold(
+ onSuccess = { latency ->
+ _messageRouterTestResult.value = ConnectionTestResult(
+ success = true,
+ message = "连接成功",
+ latency = latency
+ )
+ },
+ onFailure = { e ->
+ _messageRouterTestResult.value = ConnectionTestResult(
+ success = false,
+ message = e.message ?: "连接失败"
+ )
+ }
+ )
+ }
+ }
+
+ /**
+ * Test Account Service connection
+ */
+ fun testAccountService(serviceUrl: String) {
+ viewModelScope.launch {
+ _accountServiceTestResult.value = null
+ val result = repository.testAccountService(serviceUrl)
+ result.fold(
+ onSuccess = { latency ->
+ _accountServiceTestResult.value = ConnectionTestResult(
+ success = true,
+ message = "连接成功",
+ latency = latency
+ )
+ },
+ onFailure = { e ->
+ _accountServiceTestResult.value = ConnectionTestResult(
+ success = false,
+ message = e.message ?: "连接失败"
+ )
+ }
+ )
+ }
+ }
+
+ /**
+ * Test Kava API connection
+ */
+ fun testKavaApi(rpcUrl: String) {
+ viewModelScope.launch {
+ _kavaApiTestResult.value = null
+ val result = repository.testKavaApi(rpcUrl)
+ result.fold(
+ onSuccess = { latency ->
+ _kavaApiTestResult.value = ConnectionTestResult(
+ success = true,
+ message = "连接成功",
+ latency = latency
+ )
+ },
+ onFailure = { e ->
+ _kavaApiTestResult.value = ConnectionTestResult(
+ success = false,
+ message = e.message ?: "连接失败"
+ )
+ }
+ )
+ }
+ }
+
+ /**
+ * Clear error message
+ */
+ fun clearError() {
+ _uiState.update { it.copy(error = null) }
+ }
+
+ /**
+ * Clear success message
+ */
+ fun clearSuccess() {
+ _uiState.update { it.copy(successMessage = null) }
+ }
+
+ /**
+ * Clear created invite code
+ */
+ fun clearCreatedInviteCode() {
+ _createdInviteCode.value = null
+ }
+
+ // Wallet balances cache
+ private val _balances = MutableStateFlow