feat(android): add Android TSS Party app with full API implementation
Major changes:
- Add complete Android app (service-party-android) with Jetpack Compose UI
- Implement real account-service API calls for keygen and sign sessions:
- POST /api/v1/co-managed/sessions (create keygen session)
- GET /api/v1/co-managed/sessions/by-invite-code/{code} (validate invite)
- POST /api/v1/co-managed/sessions/{id}/join (join keygen session)
- POST /api/v1/co-managed/sign (create sign session)
- GET /api/v1/co-managed/sign/by-invite-code/{code} (validate sign invite)
- POST /api/v1/co-managed/sign/{id}/join (join sign session)
- Add QR code generation and scanning for session invites
- Remove password requirement (use empty string)
- Add floating action button for wallet creation
- Add network type aware explorer links (mainnet/testnet)
Network configuration:
- Change default network to Kava mainnet for both Electron and Android apps
- Electron: main.ts, transaction.ts, Settings.tsx, Layout.tsx
- Android: Models.kt (NetworkType.MAINNET default)
Features:
- Full TSS keygen and sign protocol via gomobile bindings
- gRPC message routing for multi-party communication
- Cross-platform compatibility with service-party-app (Electron)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ff995a827b
commit
7b6d6de801
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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.** { *; }
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Network permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Camera permission for QR code scanning (optional) -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:name=".TssPartyApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.TssParty"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.TssParty">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -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<Long?>(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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.durian.tssparty
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class TssPartyApplication : Application()
|
||||
|
|
@ -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<List<ShareRecordEntity>>
|
||||
|
||||
@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
|
||||
}
|
||||
|
|
@ -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<TssOutgoingMessage>(Channel.BUFFERED)
|
||||
val outgoingMessages: Flow<TssOutgoingMessage> = _outgoingMessages.receiveAsFlow()
|
||||
|
||||
private val _progress = Channel<Pair<Int, Int>>(Channel.BUFFERED)
|
||||
val progress: Flow<Pair<Int, Int>> = _progress.receiveAsFlow()
|
||||
|
||||
private val _errors = Channel<String>(Channel.BUFFERED)
|
||||
val errors: Flow<String> = _errors.receiveAsFlow()
|
||||
|
||||
private val _logs = Channel<String>(Channel.BUFFERED)
|
||||
val logs: Flow<String> = _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<Participant>,
|
||||
password: String
|
||||
): Result<Unit> = 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<Participant>,
|
||||
messageHash: String,
|
||||
shareData: String,
|
||||
password: String
|
||||
): Result<Unit> = 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<Unit> = 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<KeygenResult> = 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<SignResult> = 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Boolean> = 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<JoinSessionData> = 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<Boolean> = 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<String>,
|
||||
roundNumber: Int,
|
||||
messageType: String,
|
||||
payload: ByteArray
|
||||
): Result<String> = 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<IncomingMessage> = callbackFlow {
|
||||
val request = SubscribeMessagesRequest.newBuilder()
|
||||
.setSessionId(sessionId)
|
||||
.setPartyId(partyId)
|
||||
.build()
|
||||
|
||||
val observer = object : StreamObserver<MPCMessage> {
|
||||
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<SessionEventData> = callbackFlow {
|
||||
val request = SubscribeSessionEventsRequest.newBuilder()
|
||||
.setPartyId(partyId)
|
||||
.build()
|
||||
|
||||
val observer = object : StreamObserver<SessionEvent> {
|
||||
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<Boolean> = 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<Int> = 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<Participant>,
|
||||
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<String>,
|
||||
val joinTokens: Map<String, String>,
|
||||
val messageHash: String?
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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<Participant>,
|
||||
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<String>?,
|
||||
@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<Participant>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ShareRecord>,
|
||||
sessionStatus: SessionStatus,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
signSessionInfo: SignSessionInfo? = null,
|
||||
participants: List<String> = 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<Long?>(null) }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
var validationError by remember { mutableStateOf<String?>(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<ShareRecord>,
|
||||
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<String>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> = 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<String?>(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<String>,
|
||||
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("返回首页")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ShareRecord>,
|
||||
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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> = 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<String?>(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<String>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String?>(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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ConnectionTestResult?>(null) }
|
||||
var localAccountServiceResult by remember { mutableStateOf<ConnectionTestResult?>(null) }
|
||||
var localKavaApiResult by remember { mutableStateOf<ConnectionTestResult?>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<ShareRecord>,
|
||||
isConnected: Boolean,
|
||||
balances: Map<String, String> = 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<ShareRecord?>(null) }
|
||||
var showTransferDialog by remember { mutableStateOf(false) }
|
||||
var transferWallet by remember { mutableStateOf<ShareRecord?>(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<String?>(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
|
||||
}
|
||||
}
|
||||
|
|
@ -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> = _appState.asStateFlow()
|
||||
|
||||
// UI State
|
||||
private val _uiState = MutableStateFlow(MainUiState())
|
||||
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
|
||||
|
||||
// Settings
|
||||
private val _settings = MutableStateFlow(AppSettings())
|
||||
val settings: StateFlow<AppSettings> = _settings.asStateFlow()
|
||||
|
||||
// Share records
|
||||
val shares: StateFlow<List<ShareRecord>> = repository.getAllShares()
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
|
||||
|
||||
// Session status from repository
|
||||
val sessionStatus: StateFlow<SessionStatus> = repository.sessionStatus
|
||||
|
||||
// Created session invite code
|
||||
private val _createdInviteCode = MutableStateFlow<String?>(null)
|
||||
val createdInviteCode: StateFlow<String?> = _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<String?>(null)
|
||||
val currentSessionId: StateFlow<String?> = _currentSessionId.asStateFlow()
|
||||
|
||||
private val _sessionParticipants = MutableStateFlow<List<String>>(emptyList())
|
||||
val sessionParticipants: StateFlow<List<String>> = _sessionParticipants.asStateFlow()
|
||||
|
||||
private val _currentRound = MutableStateFlow(0)
|
||||
val currentRound: StateFlow<Int> = _currentRound.asStateFlow()
|
||||
|
||||
private val _publicKey = MutableStateFlow<String?>(null)
|
||||
val publicKey: StateFlow<String?> = _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<JoinKeygenSessionInfo?>(null)
|
||||
val joinSessionInfo: StateFlow<JoinKeygenSessionInfo?> = _joinSessionInfo.asStateFlow()
|
||||
|
||||
private val _joinKeygenParticipants = MutableStateFlow<List<String>>(emptyList())
|
||||
val joinKeygenParticipants: StateFlow<List<String>> = _joinKeygenParticipants.asStateFlow()
|
||||
|
||||
private val _joinKeygenRound = MutableStateFlow(0)
|
||||
val joinKeygenRound: StateFlow<Int> = _joinKeygenRound.asStateFlow()
|
||||
|
||||
private val _joinKeygenPublicKey = MutableStateFlow<String?>(null)
|
||||
val joinKeygenPublicKey: StateFlow<String?> = _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<CoSignSessionInfo?>(null)
|
||||
val coSignSessionInfo: StateFlow<CoSignSessionInfo?> = _coSignSessionInfo.asStateFlow()
|
||||
|
||||
private val _coSignParticipants = MutableStateFlow<List<String>>(emptyList())
|
||||
val coSignParticipants: StateFlow<List<String>> = _coSignParticipants.asStateFlow()
|
||||
|
||||
private val _coSignRound = MutableStateFlow(0)
|
||||
val coSignRound: StateFlow<Int> = _coSignRound.asStateFlow()
|
||||
|
||||
private val _coSignSignature = MutableStateFlow<String?>(null)
|
||||
val coSignSignature: StateFlow<String?> = _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<ConnectionTestResult?>(null)
|
||||
val messageRouterTestResult: StateFlow<ConnectionTestResult?> = _messageRouterTestResult.asStateFlow()
|
||||
|
||||
private val _accountServiceTestResult = MutableStateFlow<ConnectionTestResult?>(null)
|
||||
val accountServiceTestResult: StateFlow<ConnectionTestResult?> = _accountServiceTestResult.asStateFlow()
|
||||
|
||||
private val _kavaApiTestResult = MutableStateFlow<ConnectionTestResult?>(null)
|
||||
val kavaApiTestResult: StateFlow<ConnectionTestResult?> = _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<Map<String, String>>(emptyMap())
|
||||
val balances: StateFlow<Map<String, String>> = _balances.asStateFlow()
|
||||
|
||||
/**
|
||||
* Fetch balance for a wallet address
|
||||
*/
|
||||
fun fetchBalance(address: String) {
|
||||
viewModelScope.launch {
|
||||
val rpcUrl = _settings.value.kavaRpcUrl
|
||||
val result = repository.getBalance(address, rpcUrl)
|
||||
result.onSuccess { balance ->
|
||||
_balances.update { it + (address to balance) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch balances for all wallets
|
||||
*/
|
||||
fun fetchAllBalances() {
|
||||
viewModelScope.launch {
|
||||
shares.value.forEach { share ->
|
||||
fetchBalance(share.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Transfer / Sign Session State ==========
|
||||
|
||||
// Transfer state
|
||||
private val _transferState = MutableStateFlow(TransferState())
|
||||
val transferState: StateFlow<TransferState> = _transferState.asStateFlow()
|
||||
|
||||
// Prepared transaction
|
||||
private val _preparedTx = MutableStateFlow<TransactionUtils.PreparedTransaction?>(null)
|
||||
val preparedTx: StateFlow<TransactionUtils.PreparedTransaction?> = _preparedTx.asStateFlow()
|
||||
|
||||
// Sign session for transfer
|
||||
private val _signSessionId = MutableStateFlow<String?>(null)
|
||||
val signSessionId: StateFlow<String?> = _signSessionId.asStateFlow()
|
||||
|
||||
private val _signInviteCode = MutableStateFlow<String?>(null)
|
||||
val signInviteCode: StateFlow<String?> = _signInviteCode.asStateFlow()
|
||||
|
||||
private val _signParticipants = MutableStateFlow<List<String>>(emptyList())
|
||||
val signParticipants: StateFlow<List<String>> = _signParticipants.asStateFlow()
|
||||
|
||||
private val _signCurrentRound = MutableStateFlow(0)
|
||||
val signCurrentRound: StateFlow<Int> = _signCurrentRound.asStateFlow()
|
||||
|
||||
private val _signature = MutableStateFlow<String?>(null)
|
||||
val signature: StateFlow<String?> = _signature.asStateFlow()
|
||||
|
||||
private val _txHash = MutableStateFlow<String?>(null)
|
||||
val txHash: StateFlow<String?> = _txHash.asStateFlow()
|
||||
|
||||
/**
|
||||
* Prepare a transfer transaction
|
||||
*/
|
||||
fun prepareTransfer(shareId: Long, toAddress: String, amount: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
_transferState.update { it.copy(shareId = shareId, toAddress = toAddress, amount = amount) }
|
||||
|
||||
val share = repository.getShareById(shareId)
|
||||
if (share == null) {
|
||||
_uiState.update { it.copy(isLoading = false, error = "钱包不存在") }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val rpcUrl = _settings.value.kavaRpcUrl
|
||||
val chainId = if (_settings.value.networkType == NetworkType.TESTNET) {
|
||||
TransactionUtils.KAVA_TESTNET_CHAIN_ID
|
||||
} else {
|
||||
TransactionUtils.KAVA_MAINNET_CHAIN_ID
|
||||
}
|
||||
|
||||
val result = repository.prepareTransaction(
|
||||
from = share.address,
|
||||
to = toAddress,
|
||||
amount = amount,
|
||||
rpcUrl = rpcUrl,
|
||||
chainId = chainId
|
||||
)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { tx ->
|
||||
_preparedTx.value = tx
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sign session and start signing
|
||||
*/
|
||||
fun initiateSignSession(shareId: Long, password: String, initiatorName: String = "发起者") {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
val tx = _preparedTx.value
|
||||
if (tx == null) {
|
||||
_uiState.update { it.copy(isLoading = false, error = "交易未准备") }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val result = repository.createSignSession(
|
||||
shareId = shareId,
|
||||
messageHash = tx.signHash,
|
||||
password = password,
|
||||
initiatorName = initiatorName
|
||||
)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { sessionResult ->
|
||||
_signSessionId.value = sessionResult.sessionId
|
||||
_signInviteCode.value = sessionResult.inviteCode
|
||||
_signParticipants.value = listOf(initiatorName)
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
|
||||
// Start signing process
|
||||
startSigningProcess(sessionResult.sessionId, shareId, password)
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the TSS signing process
|
||||
*/
|
||||
private fun startSigningProcess(sessionId: String, shareId: Long, password: String) {
|
||||
viewModelScope.launch {
|
||||
val startResult = repository.startSigning(sessionId, shareId, password)
|
||||
|
||||
if (startResult.isFailure) {
|
||||
_uiState.update { it.copy(error = startResult.exceptionOrNull()?.message) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Wait for signature
|
||||
val signResult = repository.waitForSignature()
|
||||
|
||||
signResult.fold(
|
||||
onSuccess = { result ->
|
||||
_signature.value = result.signature
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast the signed transaction
|
||||
*/
|
||||
fun broadcastTransaction() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
val tx = _preparedTx.value
|
||||
val sig = _signature.value
|
||||
|
||||
if (tx == null || sig == null) {
|
||||
_uiState.update { it.copy(isLoading = false, error = "交易或签名缺失") }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val rpcUrl = _settings.value.kavaRpcUrl
|
||||
val result = repository.broadcastTransaction(tx, sig, rpcUrl)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { hash ->
|
||||
_txHash.value = hash
|
||||
_uiState.update { it.copy(isLoading = false, successMessage = "交易已广播!") }
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset transfer state
|
||||
*/
|
||||
fun resetTransferState() {
|
||||
_transferState.value = TransferState()
|
||||
_preparedTx.value = null
|
||||
_signSessionId.value = null
|
||||
_signInviteCode.value = null
|
||||
_signParticipants.value = emptyList()
|
||||
_signCurrentRound.value = 0
|
||||
_signature.value = null
|
||||
_txHash.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallet by ID
|
||||
*/
|
||||
fun getWalletById(shareId: Long): ShareRecord? {
|
||||
return shares.value.find { it.id == shareId }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
repository.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI State
|
||||
*/
|
||||
data class MainUiState(
|
||||
val isConnected: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val successMessage: String? = null,
|
||||
val lastCreatedAddress: String? = null,
|
||||
val lastSignature: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Transfer state
|
||||
*/
|
||||
data class TransferState(
|
||||
val shareId: Long = 0,
|
||||
val toAddress: String = "",
|
||||
val amount: String = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* Join keygen session info (from validateInviteCode)
|
||||
*/
|
||||
data class JoinKeygenSessionInfo(
|
||||
val sessionId: String,
|
||||
val walletName: String,
|
||||
val thresholdT: Int,
|
||||
val thresholdN: Int,
|
||||
val initiator: String,
|
||||
val currentParticipants: Int,
|
||||
val totalParticipants: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* CoSign session info (from validateSignInviteCode)
|
||||
*/
|
||||
data class CoSignSessionInfo(
|
||||
val sessionId: String,
|
||||
val keygenSessionId: String,
|
||||
val walletName: String,
|
||||
val messageHash: String,
|
||||
val thresholdT: Int,
|
||||
val thresholdN: Int,
|
||||
val currentParticipants: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Connection test result for Settings screen
|
||||
*/
|
||||
data class ConnectionTestResult(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val latency: Long? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package com.durian.tssparty.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
// Durian Green Theme Colors
|
||||
private val DurianGreen = Color(0xFF4CAF50)
|
||||
private val DurianGreenDark = Color(0xFF388E3C)
|
||||
private val DurianGreenLight = Color(0xFF81C784)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = DurianGreenLight,
|
||||
onPrimary = Color.Black,
|
||||
primaryContainer = DurianGreenDark,
|
||||
onPrimaryContainer = Color.White,
|
||||
secondary = Color(0xFFB2DFDB),
|
||||
onSecondary = Color.Black,
|
||||
background = Color(0xFF121212),
|
||||
onBackground = Color.White,
|
||||
surface = Color(0xFF1E1E1E),
|
||||
onSurface = Color.White,
|
||||
error = Color(0xFFCF6679),
|
||||
onError = Color.Black
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = DurianGreen,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = DurianGreenLight,
|
||||
onPrimaryContainer = Color.Black,
|
||||
secondary = Color(0xFF00796B),
|
||||
onSecondary = Color.White,
|
||||
background = Color(0xFFFAFAFA),
|
||||
onBackground = Color.Black,
|
||||
surface = Color.White,
|
||||
onSurface = Color.Black,
|
||||
error = Color(0xFFB00020),
|
||||
onError = Color.White
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TssPartyTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.durian.tssparty.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
package com.durian.tssparty.util
|
||||
|
||||
import org.bouncycastle.jcajce.provider.digest.Keccak
|
||||
import org.bouncycastle.jcajce.provider.digest.RIPEMD160
|
||||
import org.bouncycastle.jcajce.provider.digest.SHA256
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Utility functions for address derivation
|
||||
*/
|
||||
object AddressUtils {
|
||||
|
||||
/**
|
||||
* Derive Kava address from compressed public key
|
||||
* Kava uses Bech32 with "kava" prefix
|
||||
*/
|
||||
fun deriveKavaAddress(compressedPubKey: ByteArray): String {
|
||||
// 1. Decompress public key if compressed (33 bytes -> 65 bytes)
|
||||
val uncompressedPubKey = if (compressedPubKey.size == 33) {
|
||||
decompressPublicKey(compressedPubKey)
|
||||
} else {
|
||||
compressedPubKey
|
||||
}
|
||||
|
||||
// 2. For Cosmos/Kava: SHA256 -> RIPEMD160
|
||||
val sha256 = SHA256.Digest().digest(compressedPubKey)
|
||||
val ripemd160 = RIPEMD160.Digest().digest(sha256)
|
||||
|
||||
// 3. Bech32 encode with "kava" prefix
|
||||
return Bech32.encode("kava", convertBits(ripemd160, 8, 5, true))
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive EVM address from public key (for Kava EVM compatibility)
|
||||
*/
|
||||
fun deriveEvmAddress(compressedPubKey: ByteArray): String {
|
||||
// 1. Decompress if needed
|
||||
val uncompressedPubKey = if (compressedPubKey.size == 33) {
|
||||
decompressPublicKey(compressedPubKey)
|
||||
} else {
|
||||
compressedPubKey
|
||||
}
|
||||
|
||||
// 2. Take last 64 bytes (remove 0x04 prefix)
|
||||
val pubKeyNoPrefix = if (uncompressedPubKey.size == 65) {
|
||||
uncompressedPubKey.sliceArray(1..64)
|
||||
} else {
|
||||
uncompressedPubKey
|
||||
}
|
||||
|
||||
// 3. Keccak256 hash
|
||||
val keccak = Keccak.Digest256().digest(pubKeyNoPrefix)
|
||||
|
||||
// 4. Take last 20 bytes
|
||||
val addressBytes = keccak.sliceArray(12..31)
|
||||
|
||||
// 5. Hex encode with 0x prefix
|
||||
return "0x" + addressBytes.toHexString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a compressed secp256k1 public key
|
||||
*/
|
||||
private fun decompressPublicKey(compressed: ByteArray): ByteArray {
|
||||
require(compressed.size == 33) { "Invalid compressed public key size" }
|
||||
|
||||
val prefix = compressed[0].toInt() and 0xFF
|
||||
require(prefix == 0x02 || prefix == 0x03) { "Invalid compression prefix" }
|
||||
|
||||
val x = compressed.sliceArray(1..32)
|
||||
|
||||
// secp256k1 curve parameters
|
||||
val p = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F".toBigInteger(16)
|
||||
val xBigInt = x.toBigInteger()
|
||||
|
||||
// y² = x³ + 7 (mod p)
|
||||
val ySquared = (xBigInt.pow(3) + 7.toBigInteger()).mod(p)
|
||||
|
||||
// Calculate y using modular square root
|
||||
var y = ySquared.modPow((p + 1.toBigInteger()) / 4.toBigInteger(), p)
|
||||
|
||||
// Check parity
|
||||
val isOdd = prefix == 0x03
|
||||
if (y.testBit(0) != isOdd) {
|
||||
y = p - y
|
||||
}
|
||||
|
||||
// Build uncompressed key: 0x04 || x || y
|
||||
val result = ByteArray(65)
|
||||
result[0] = 0x04
|
||||
val xBytes = x
|
||||
val yBytes = y.toByteArray32()
|
||||
System.arraycopy(xBytes, 0, result, 1, 32)
|
||||
System.arraycopy(yBytes, 0, result, 33, 32)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert between bit groups for Bech32
|
||||
*/
|
||||
private fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): ByteArray {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
val result = mutableListOf<Byte>()
|
||||
val maxv = (1 shl toBits) - 1
|
||||
|
||||
for (value in data) {
|
||||
val v = value.toInt() and 0xFF
|
||||
acc = (acc shl fromBits) or v
|
||||
bits += fromBits
|
||||
while (bits >= toBits) {
|
||||
bits -= toBits
|
||||
result.add(((acc shr bits) and maxv).toByte())
|
||||
}
|
||||
}
|
||||
|
||||
if (pad && bits > 0) {
|
||||
result.add(((acc shl (toBits - bits)) and maxv).toByte())
|
||||
}
|
||||
|
||||
return result.toByteArray()
|
||||
}
|
||||
|
||||
private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun ByteArray.toBigInteger(): java.math.BigInteger {
|
||||
return java.math.BigInteger(1, this)
|
||||
}
|
||||
|
||||
private fun java.math.BigInteger.toByteArray32(): ByteArray {
|
||||
val bytes = this.toByteArray()
|
||||
return when {
|
||||
bytes.size == 32 -> bytes
|
||||
bytes.size > 32 -> bytes.sliceArray((bytes.size - 32) until bytes.size)
|
||||
else -> ByteArray(32 - bytes.size) + bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bech32 encoding utilities
|
||||
*/
|
||||
object Bech32 {
|
||||
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private val GENERATOR = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
|
||||
|
||||
fun encode(hrp: String, data: ByteArray): String {
|
||||
val combined = data.map { it.toInt() and 0xFF }.toIntArray()
|
||||
val checksum = createChecksum(hrp, combined)
|
||||
val result = StringBuilder(hrp).append("1")
|
||||
for (d in combined) result.append(CHARSET[d])
|
||||
for (d in checksum) result.append(CHARSET[d])
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
private fun polymod(values: IntArray): Int {
|
||||
var chk = 1
|
||||
for (v in values) {
|
||||
val top = chk shr 25
|
||||
chk = ((chk and 0x1ffffff) shl 5) xor v
|
||||
for (i in 0..4) {
|
||||
if ((top shr i) and 1 == 1) {
|
||||
chk = chk xor GENERATOR[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
|
||||
private fun hrpExpand(hrp: String): IntArray {
|
||||
val result = IntArray(hrp.length * 2 + 1)
|
||||
for (i in hrp.indices) {
|
||||
result[i] = hrp[i].code shr 5
|
||||
result[i + hrp.length + 1] = hrp[i].code and 31
|
||||
}
|
||||
result[hrp.length] = 0
|
||||
return result
|
||||
}
|
||||
|
||||
private fun createChecksum(hrp: String, data: IntArray): IntArray {
|
||||
val values = hrpExpand(hrp) + data + intArrayOf(0, 0, 0, 0, 0, 0)
|
||||
val polymod = polymod(values) xor 1
|
||||
return IntArray(6) { (polymod shr (5 * (5 - it))) and 31 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,500 @@
|
|||
package com.durian.tssparty.util
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.bouncycastle.jcajce.provider.digest.Keccak
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Transaction utilities for Kava EVM
|
||||
* Matches service-party-app/src/utils/transaction.ts
|
||||
*/
|
||||
object TransactionUtils {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
// Chain IDs
|
||||
const val KAVA_TESTNET_CHAIN_ID = 2221
|
||||
const val KAVA_MAINNET_CHAIN_ID = 2222
|
||||
|
||||
/**
|
||||
* Prepared transaction ready for signing
|
||||
*/
|
||||
data class PreparedTransaction(
|
||||
val nonce: BigInteger,
|
||||
val gasPrice: BigInteger,
|
||||
val gasLimit: BigInteger,
|
||||
val to: String,
|
||||
val from: String,
|
||||
val value: BigInteger,
|
||||
val data: ByteArray = ByteArray(0),
|
||||
val chainId: Int,
|
||||
val signHash: String, // Hash to be signed (hex with 0x prefix)
|
||||
val rawTxForSigning: ByteArray // RLP encoded tx for signing
|
||||
)
|
||||
|
||||
/**
|
||||
* Transaction parameters for preparation
|
||||
*/
|
||||
data class TransactionParams(
|
||||
val from: String,
|
||||
val to: String,
|
||||
val amount: String, // In KAVA (not wei)
|
||||
val rpcUrl: String,
|
||||
val chainId: Int = KAVA_TESTNET_CHAIN_ID
|
||||
)
|
||||
|
||||
/**
|
||||
* Prepare a transaction for signing
|
||||
* Gets nonce, gas price, estimates gas, and calculates sign hash
|
||||
*/
|
||||
suspend fun prepareTransaction(params: TransactionParams): Result<PreparedTransaction> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// 1. Get nonce
|
||||
val nonce = getNonce(params.from, params.rpcUrl).getOrThrow()
|
||||
|
||||
// 2. Get gas price
|
||||
val gasPrice = getGasPrice(params.rpcUrl).getOrThrow()
|
||||
|
||||
// 3. Convert amount to wei (1 KAVA = 10^18 wei)
|
||||
val valueWei = kavaToWei(params.amount)
|
||||
|
||||
// 4. Estimate gas
|
||||
val gasLimit = estimateGas(
|
||||
from = params.from,
|
||||
to = params.to,
|
||||
value = valueWei,
|
||||
rpcUrl = params.rpcUrl
|
||||
).getOrElse { BigInteger.valueOf(21000) } // Default for simple transfer
|
||||
|
||||
// 5. RLP encode for signing (Legacy Type 0 format)
|
||||
// Format: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
|
||||
val rawTxForSigning = rlpEncodeForSigning(
|
||||
nonce = nonce,
|
||||
gasPrice = gasPrice,
|
||||
gasLimit = gasLimit,
|
||||
to = params.to,
|
||||
value = valueWei,
|
||||
data = ByteArray(0),
|
||||
chainId = params.chainId
|
||||
)
|
||||
|
||||
// 6. Calculate Keccak-256 hash
|
||||
val signHash = keccak256(rawTxForSigning)
|
||||
|
||||
Result.success(PreparedTransaction(
|
||||
nonce = nonce,
|
||||
gasPrice = gasPrice,
|
||||
gasLimit = gasLimit,
|
||||
to = params.to,
|
||||
from = params.from,
|
||||
value = valueWei,
|
||||
chainId = params.chainId,
|
||||
signHash = "0x" + signHash.toHexString(),
|
||||
rawTxForSigning = rawTxForSigning
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize transaction with signature
|
||||
* Returns the signed raw transaction hex string ready for broadcast
|
||||
*/
|
||||
fun finalizeTransaction(
|
||||
preparedTx: PreparedTransaction,
|
||||
r: ByteArray,
|
||||
s: ByteArray,
|
||||
recoveryId: Int
|
||||
): String {
|
||||
// Calculate EIP-155 v value
|
||||
// v = chainId * 2 + 35 + recovery_id
|
||||
val v = preparedTx.chainId * 2 + 35 + recoveryId
|
||||
|
||||
// RLP encode signed transaction
|
||||
// Format: [nonce, gasPrice, gasLimit, to, value, data, v, r, s]
|
||||
val signedTx = rlpEncodeSigned(
|
||||
nonce = preparedTx.nonce,
|
||||
gasPrice = preparedTx.gasPrice,
|
||||
gasLimit = preparedTx.gasLimit,
|
||||
to = preparedTx.to,
|
||||
value = preparedTx.value,
|
||||
data = preparedTx.data,
|
||||
v = BigInteger.valueOf(v.toLong()),
|
||||
r = BigInteger(1, r),
|
||||
s = BigInteger(1, s)
|
||||
)
|
||||
|
||||
return "0x" + signedTx.toHexString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast signed transaction to the network
|
||||
*/
|
||||
suspend fun broadcastTransaction(signedTx: String, rpcUrl: String): Result<String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val requestBody = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_sendRawTransaction",
|
||||
"params": ["$signedTx"],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = 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"))
|
||||
|
||||
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
|
||||
if (json.has("error")) {
|
||||
val errorMsg = json.get("error").asJsonObject.get("message").asString
|
||||
return@withContext Result.failure(Exception(errorMsg))
|
||||
}
|
||||
|
||||
val txHash = json.get("result").asString
|
||||
Result.success(txHash)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transaction receipt (for confirmation)
|
||||
*/
|
||||
suspend fun getTransactionReceipt(txHash: String, rpcUrl: String): Result<TransactionReceipt?> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val requestBody = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_getTransactionReceipt",
|
||||
"params": ["$txHash"],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = 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"))
|
||||
|
||||
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
|
||||
if (json.has("error")) {
|
||||
val errorMsg = json.get("error").asJsonObject.get("message").asString
|
||||
return@withContext Result.failure(Exception(errorMsg))
|
||||
}
|
||||
|
||||
val result = json.get("result")
|
||||
if (result.isJsonNull) {
|
||||
// Transaction not yet mined
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
|
||||
val receipt = result.asJsonObject
|
||||
Result.success(TransactionReceipt(
|
||||
transactionHash = receipt.get("transactionHash").asString,
|
||||
blockNumber = receipt.get("blockNumber").asString,
|
||||
status = receipt.get("status").asString == "0x1",
|
||||
gasUsed = BigInteger(receipt.get("gasUsed").asString.removePrefix("0x"), 16)
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
data class TransactionReceipt(
|
||||
val transactionHash: String,
|
||||
val blockNumber: String,
|
||||
val status: Boolean,
|
||||
val gasUsed: BigInteger
|
||||
)
|
||||
|
||||
// ========== RPC Methods ==========
|
||||
|
||||
private suspend fun getNonce(address: String, rpcUrl: String): Result<BigInteger> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val requestBody = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_getTransactionCount",
|
||||
"params": ["$address", "pending"],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = 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"))
|
||||
|
||||
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 hexNonce = json.get("result").asString
|
||||
Result.success(BigInteger(hexNonce.removePrefix("0x"), 16))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getGasPrice(rpcUrl: String): Result<BigInteger> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val requestBody = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_gasPrice",
|
||||
"params": [],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = 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"))
|
||||
|
||||
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 hexGasPrice = json.get("result").asString
|
||||
Result.success(BigInteger(hexGasPrice.removePrefix("0x"), 16))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun estimateGas(
|
||||
from: String,
|
||||
to: String,
|
||||
value: BigInteger,
|
||||
rpcUrl: String
|
||||
): Result<BigInteger> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val valueHex = "0x" + value.toString(16)
|
||||
val requestBody = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_estimateGas",
|
||||
"params": [{
|
||||
"from": "$from",
|
||||
"to": "$to",
|
||||
"value": "$valueHex"
|
||||
}],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = 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"))
|
||||
|
||||
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 hexGas = json.get("result").asString
|
||||
// Add 10% buffer
|
||||
val gas = BigInteger(hexGas.removePrefix("0x"), 16)
|
||||
val gasWithBuffer = gas.multiply(BigInteger.valueOf(110)).divide(BigInteger.valueOf(100))
|
||||
Result.success(gasWithBuffer)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Utility Methods ==========
|
||||
|
||||
fun kavaToWei(kava: String): BigInteger {
|
||||
val decimal = BigDecimal(kava)
|
||||
val weiDecimal = decimal.multiply(BigDecimal("1000000000000000000"))
|
||||
return weiDecimal.toBigInteger()
|
||||
}
|
||||
|
||||
fun weiToKava(wei: BigInteger): String {
|
||||
val weiDecimal = BigDecimal(wei)
|
||||
val kavaDecimal = weiDecimal.divide(BigDecimal("1000000000000000000"), 6, java.math.RoundingMode.DOWN)
|
||||
return kavaDecimal.toPlainString()
|
||||
}
|
||||
|
||||
fun weiToGwei(wei: BigInteger): String {
|
||||
val weiDecimal = BigDecimal(wei)
|
||||
val gweiDecimal = weiDecimal.divide(BigDecimal("1000000000"), 2, java.math.RoundingMode.DOWN)
|
||||
return gweiDecimal.toPlainString()
|
||||
}
|
||||
|
||||
private fun keccak256(data: ByteArray): ByteArray {
|
||||
val keccak = Keccak.Digest256()
|
||||
return keccak.digest(data)
|
||||
}
|
||||
|
||||
// ========== RLP Encoding ==========
|
||||
|
||||
/**
|
||||
* RLP encode transaction for signing (EIP-155)
|
||||
* Format: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
|
||||
*/
|
||||
private fun rlpEncodeForSigning(
|
||||
nonce: BigInteger,
|
||||
gasPrice: BigInteger,
|
||||
gasLimit: BigInteger,
|
||||
to: String,
|
||||
value: BigInteger,
|
||||
data: ByteArray,
|
||||
chainId: Int
|
||||
): ByteArray {
|
||||
val items = listOf(
|
||||
rlpEncodeInteger(nonce),
|
||||
rlpEncodeInteger(gasPrice),
|
||||
rlpEncodeInteger(gasLimit),
|
||||
rlpEncodeAddress(to),
|
||||
rlpEncodeInteger(value),
|
||||
rlpEncodeBytes(data),
|
||||
rlpEncodeInteger(BigInteger.valueOf(chainId.toLong())),
|
||||
rlpEncodeInteger(BigInteger.ZERO),
|
||||
rlpEncodeInteger(BigInteger.ZERO)
|
||||
)
|
||||
return rlpEncodeList(items)
|
||||
}
|
||||
|
||||
/**
|
||||
* RLP encode signed transaction
|
||||
* Format: [nonce, gasPrice, gasLimit, to, value, data, v, r, s]
|
||||
*/
|
||||
private fun rlpEncodeSigned(
|
||||
nonce: BigInteger,
|
||||
gasPrice: BigInteger,
|
||||
gasLimit: BigInteger,
|
||||
to: String,
|
||||
value: BigInteger,
|
||||
data: ByteArray,
|
||||
v: BigInteger,
|
||||
r: BigInteger,
|
||||
s: BigInteger
|
||||
): ByteArray {
|
||||
val items = listOf(
|
||||
rlpEncodeInteger(nonce),
|
||||
rlpEncodeInteger(gasPrice),
|
||||
rlpEncodeInteger(gasLimit),
|
||||
rlpEncodeAddress(to),
|
||||
rlpEncodeInteger(value),
|
||||
rlpEncodeBytes(data),
|
||||
rlpEncodeInteger(v),
|
||||
rlpEncodeInteger(r),
|
||||
rlpEncodeInteger(s)
|
||||
)
|
||||
return rlpEncodeList(items)
|
||||
}
|
||||
|
||||
private fun rlpEncodeInteger(value: BigInteger): ByteArray {
|
||||
if (value == BigInteger.ZERO) {
|
||||
return byteArrayOf(0x80.toByte())
|
||||
}
|
||||
val bytes = value.toByteArray()
|
||||
// Remove leading zero if present
|
||||
val trimmed = if (bytes[0] == 0.toByte() && bytes.size > 1) {
|
||||
bytes.copyOfRange(1, bytes.size)
|
||||
} else {
|
||||
bytes
|
||||
}
|
||||
return rlpEncodeBytes(trimmed)
|
||||
}
|
||||
|
||||
private fun rlpEncodeAddress(address: String): ByteArray {
|
||||
val cleanAddress = address.removePrefix("0x")
|
||||
val bytes = cleanAddress.hexToByteArray()
|
||||
return rlpEncodeBytes(bytes)
|
||||
}
|
||||
|
||||
private fun rlpEncodeBytes(bytes: ByteArray): ByteArray {
|
||||
return when {
|
||||
bytes.size == 1 && bytes[0].toInt() and 0xFF < 0x80 -> bytes
|
||||
bytes.size <= 55 -> {
|
||||
val result = ByteArray(1 + bytes.size)
|
||||
result[0] = (0x80 + bytes.size).toByte()
|
||||
System.arraycopy(bytes, 0, result, 1, bytes.size)
|
||||
result
|
||||
}
|
||||
else -> {
|
||||
val lengthBytes = bytes.size.toBigInteger().toByteArray().let { arr ->
|
||||
if (arr[0] == 0.toByte() && arr.size > 1) arr.copyOfRange(1, arr.size) else arr
|
||||
}
|
||||
val result = ByteArray(1 + lengthBytes.size + bytes.size)
|
||||
result[0] = (0xB7 + lengthBytes.size).toByte()
|
||||
System.arraycopy(lengthBytes, 0, result, 1, lengthBytes.size)
|
||||
System.arraycopy(bytes, 0, result, 1 + lengthBytes.size, bytes.size)
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rlpEncodeList(items: List<ByteArray>): ByteArray {
|
||||
val concatenated = items.fold(ByteArray(0)) { acc, item -> acc + item }
|
||||
return when {
|
||||
concatenated.size <= 55 -> {
|
||||
val result = ByteArray(1 + concatenated.size)
|
||||
result[0] = (0xC0 + concatenated.size).toByte()
|
||||
System.arraycopy(concatenated, 0, result, 1, concatenated.size)
|
||||
result
|
||||
}
|
||||
else -> {
|
||||
val lengthBytes = concatenated.size.toBigInteger().toByteArray().let { arr ->
|
||||
if (arr[0] == 0.toByte() && arr.size > 1) arr.copyOfRange(1, arr.size) else arr
|
||||
}
|
||||
val result = ByteArray(1 + lengthBytes.size + concatenated.size)
|
||||
result[0] = (0xF7 + lengthBytes.size).toByte()
|
||||
System.arraycopy(lengthBytes, 0, result, 1, lengthBytes.size)
|
||||
System.arraycopy(concatenated, 0, result, 1 + lengthBytes.size, concatenated.size)
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Extension Functions ==========
|
||||
|
||||
private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package mpc.router.v1;
|
||||
|
||||
option java_package = "com.durian.tssparty.grpc";
|
||||
option java_outer_classname = "MessageRouterProto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
// MessageRouter service handles MPC message routing
|
||||
service MessageRouter {
|
||||
// RouteMessage routes a message from one party to others
|
||||
rpc RouteMessage(RouteMessageRequest) returns (RouteMessageResponse);
|
||||
|
||||
// SubscribeMessages subscribes to messages for a party (streaming)
|
||||
rpc SubscribeMessages(SubscribeMessagesRequest) returns (stream MPCMessage);
|
||||
|
||||
// GetPendingMessages retrieves pending messages (polling alternative)
|
||||
rpc GetPendingMessages(GetPendingMessagesRequest) returns (GetPendingMessagesResponse);
|
||||
|
||||
// AcknowledgeMessage acknowledges receipt of a message
|
||||
rpc AcknowledgeMessage(AcknowledgeMessageRequest) returns (AcknowledgeMessageResponse);
|
||||
|
||||
// RegisterParty registers a party with the message router
|
||||
rpc RegisterParty(RegisterPartyRequest) returns (RegisterPartyResponse);
|
||||
|
||||
// Heartbeat sends a heartbeat to keep the party alive
|
||||
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
|
||||
|
||||
// SubscribeSessionEvents subscribes to session lifecycle events
|
||||
rpc SubscribeSessionEvents(SubscribeSessionEventsRequest) returns (stream SessionEvent);
|
||||
|
||||
// JoinSession joins a session (proxied to Session Coordinator)
|
||||
rpc JoinSession(JoinSessionRequest) returns (JoinSessionResponse);
|
||||
|
||||
// MarkPartyReady marks a party as ready
|
||||
rpc MarkPartyReady(MarkPartyReadyRequest) returns (MarkPartyReadyResponse);
|
||||
|
||||
// ReportCompletion reports protocol completion
|
||||
rpc ReportCompletion(ReportCompletionRequest) returns (ReportCompletionResponse);
|
||||
|
||||
// GetSessionStatus gets session status
|
||||
rpc GetSessionStatus(GetSessionStatusRequest) returns (GetSessionStatusResponse);
|
||||
}
|
||||
|
||||
message RouteMessageRequest {
|
||||
string session_id = 1;
|
||||
string from_party = 2;
|
||||
repeated string to_parties = 3;
|
||||
int32 round_number = 4;
|
||||
string message_type = 5;
|
||||
bytes payload = 6;
|
||||
}
|
||||
|
||||
message RouteMessageResponse {
|
||||
bool success = 1;
|
||||
string message_id = 2;
|
||||
}
|
||||
|
||||
message SubscribeMessagesRequest {
|
||||
string session_id = 1;
|
||||
string party_id = 2;
|
||||
}
|
||||
|
||||
message MPCMessage {
|
||||
string message_id = 1;
|
||||
string session_id = 2;
|
||||
string from_party = 3;
|
||||
bool is_broadcast = 4;
|
||||
int32 round_number = 5;
|
||||
string message_type = 6;
|
||||
bytes payload = 7;
|
||||
int64 created_at = 8;
|
||||
}
|
||||
|
||||
message GetPendingMessagesRequest {
|
||||
string session_id = 1;
|
||||
string party_id = 2;
|
||||
int64 after_timestamp = 3;
|
||||
}
|
||||
|
||||
message GetPendingMessagesResponse {
|
||||
repeated MPCMessage messages = 1;
|
||||
}
|
||||
|
||||
message NotificationChannel {
|
||||
string email = 1;
|
||||
string phone = 2;
|
||||
string push_token = 3;
|
||||
}
|
||||
|
||||
message RegisterPartyRequest {
|
||||
string party_id = 1;
|
||||
string party_role = 2;
|
||||
string version = 3;
|
||||
NotificationChannel notification = 4;
|
||||
}
|
||||
|
||||
message RegisterPartyResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
int64 registered_at = 3;
|
||||
}
|
||||
|
||||
message SubscribeSessionEventsRequest {
|
||||
string party_id = 1;
|
||||
repeated string event_types = 2;
|
||||
}
|
||||
|
||||
message SessionEvent {
|
||||
string event_id = 1;
|
||||
string event_type = 2;
|
||||
string session_id = 3;
|
||||
int32 threshold_n = 4;
|
||||
int32 threshold_t = 5;
|
||||
repeated string selected_parties = 6;
|
||||
map<string, string> join_tokens = 7;
|
||||
bytes message_hash = 8;
|
||||
int64 created_at = 9;
|
||||
int64 expires_at = 10;
|
||||
}
|
||||
|
||||
message AcknowledgeMessageRequest {
|
||||
string message_id = 1;
|
||||
string party_id = 2;
|
||||
string session_id = 3;
|
||||
bool success = 4;
|
||||
string error_message = 5;
|
||||
}
|
||||
|
||||
message AcknowledgeMessageResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message HeartbeatRequest {
|
||||
string party_id = 1;
|
||||
int64 timestamp = 2;
|
||||
}
|
||||
|
||||
message HeartbeatResponse {
|
||||
bool success = 1;
|
||||
int64 server_timestamp = 2;
|
||||
int32 pending_messages = 3;
|
||||
}
|
||||
|
||||
message DeviceInfo {
|
||||
string device_type = 1;
|
||||
string device_id = 2;
|
||||
string platform = 3;
|
||||
string app_version = 4;
|
||||
}
|
||||
|
||||
message PartyInfo {
|
||||
string party_id = 1;
|
||||
int32 party_index = 2;
|
||||
DeviceInfo device_info = 3;
|
||||
}
|
||||
|
||||
message SessionInfo {
|
||||
string session_id = 1;
|
||||
string session_type = 2;
|
||||
int32 threshold_n = 3;
|
||||
int32 threshold_t = 4;
|
||||
bytes message_hash = 5;
|
||||
string status = 6;
|
||||
string keygen_session_id = 7;
|
||||
}
|
||||
|
||||
message JoinSessionRequest {
|
||||
string session_id = 1;
|
||||
string party_id = 2;
|
||||
string join_token = 3;
|
||||
DeviceInfo device_info = 4;
|
||||
}
|
||||
|
||||
message JoinSessionResponse {
|
||||
bool success = 1;
|
||||
SessionInfo session_info = 2;
|
||||
repeated PartyInfo other_parties = 3;
|
||||
int32 party_index = 4;
|
||||
}
|
||||
|
||||
message MarkPartyReadyRequest {
|
||||
string session_id = 1;
|
||||
string party_id = 2;
|
||||
}
|
||||
|
||||
message MarkPartyReadyResponse {
|
||||
bool success = 1;
|
||||
bool all_ready = 2;
|
||||
}
|
||||
|
||||
message ReportCompletionRequest {
|
||||
string session_id = 1;
|
||||
string party_id = 2;
|
||||
bytes public_key = 3;
|
||||
bytes signature = 4;
|
||||
}
|
||||
|
||||
message ReportCompletionResponse {
|
||||
bool success = 1;
|
||||
bool all_completed = 2;
|
||||
}
|
||||
|
||||
message GetSessionStatusRequest {
|
||||
string session_id = 1;
|
||||
}
|
||||
|
||||
message GetSessionStatusResponse {
|
||||
string session_id = 1;
|
||||
string status = 2;
|
||||
int32 threshold_n = 3;
|
||||
int32 threshold_t = 4;
|
||||
repeated PartyInfo participants = 5;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Simple wallet icon -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,30 L78,30 C80.2,30 82,31.8 82,34 L82,74 C82,76.2 80.2,78 78,78 L30,78 C27.8,78 26,76.2 26,74 L26,34 C26,31.8 27.8,30 30,30 L54,30 Z M54,26 L30,26 C25.6,26 22,29.6 22,34 L22,74 C22,78.4 25.6,82 30,82 L78,82 C82.4,82 86,78.4 86,74 L86,34 C86,29.6 82.4,26 78,26 L54,26 Z"/>
|
||||
|
||||
<!-- Key symbol -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,44 C58.4,44 62,47.6 62,52 C62,54.8 60.6,57.2 58.4,58.6 L58.4,66 L50,66 L50,58.6 C47.4,57.2 46,54.8 46,52 C46,47.6 49.6,44 54,44 Z M54,48 C51.8,48 50,49.8 50,52 C50,53.4 50.8,54.6 52,55.2 L52,62 L56,62 L56,55.2 C57.2,54.6 58,53.4 58,52 C58,49.8 56.2,48 54,48 Z"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/green_primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/green_primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="green_primary">#4CAF50</color>
|
||||
<color name="green_dark">#388E3C</color>
|
||||
<color name="green_light">#81C784</color>
|
||||
<color name="white">#FFFFFF</color>
|
||||
<color name="black">#000000</color>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">TSS Party</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.TssParty" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@color/green_primary</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<include domain="sharedpref" path="."/>
|
||||
<include domain="database" path="."/>
|
||||
<exclude domain="database" path="tss_party.db"/>
|
||||
</full-backup-content>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<include domain="sharedpref" path="."/>
|
||||
<include domain="database" path="."/>
|
||||
<exclude domain="database" path="tss_party.db"/>
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<include domain="sharedpref" path="."/>
|
||||
<include domain="database" path="."/>
|
||||
<exclude domain="database" path="tss_party.db"/>
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// Top-level build file
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.21" apply false
|
||||
id("com.google.dagger.hilt.android") version "2.48.1" apply false
|
||||
id("com.google.protobuf") version "0.9.4" apply false
|
||||
}
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete::class) {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# Project-wide Gradle settings
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
|
||||
# Android settings
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
# Kotlin settings
|
||||
kotlin.code.style=official
|
||||
BIN
backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX://www.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html
|
||||
#
|
||||
# (2) You need a Java Runtime Environment (JRE) to run Gradle.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MSYS* | MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kass://www.gradle.org/
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Annoying
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Annoying
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell://www.gnu.org/software/bash/manual/html_node/Quoting.html
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "TSSPartyAndroid"
|
||||
include(":app")
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
@echo off
|
||||
REM Build TSS library for Android using gomobile
|
||||
|
||||
echo === Building TSS Library for Android ===
|
||||
|
||||
REM Check if gomobile is available
|
||||
where gomobile >nul 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo Installing gomobile...
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
)
|
||||
|
||||
REM Download dependencies
|
||||
echo Downloading Go dependencies...
|
||||
go mod tidy
|
||||
|
||||
REM Build for Android
|
||||
echo Building Android AAR...
|
||||
gomobile bind -target=android -androidapi=26 -o ..\app\libs\tsslib.aar .
|
||||
|
||||
echo === Build complete! ===
|
||||
echo Output: ..\app\libs\tsslib.aar
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
#!/bin/bash
|
||||
# Build TSS library for Android using gomobile
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Building TSS Library for Android ==="
|
||||
|
||||
# Check if gomobile is installed
|
||||
if ! command -v gomobile &> /dev/null; then
|
||||
echo "Installing gomobile..."
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
fi
|
||||
|
||||
# Download dependencies
|
||||
echo "Downloading Go dependencies..."
|
||||
go mod tidy
|
||||
|
||||
# Build for Android
|
||||
echo "Building Android AAR..."
|
||||
gomobile bind -target=android -androidapi=26 -o ../app/libs/tsslib.aar .
|
||||
|
||||
echo "=== Build complete! ==="
|
||||
echo "Output: ../app/libs/tsslib.aar"
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
module github.com/rwadurian/tsslib
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/bnb-chain/tss-lib/v2 v2.0.2
|
||||
|
||||
require (
|
||||
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
|
||||
github.com/btcsuite/btcd v0.23.4 // indirect
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
|
||||
github.com/btcsuite/btcutil v1.0.2 // indirect
|
||||
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/ipfs/go-log v1.0.5 // indirect
|
||||
github.com/ipfs/go-log/v2 v2.5.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
golang.org/x/crypto v0.13.0 // indirect
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
)
|
||||
|
||||
// Replace to fix tss-lib dependency issue with ed25519
|
||||
replace github.com/agl/ed25519 => github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI=
|
||||
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bnb-chain/tss-lib/v2 v2.0.2 h1:dL2GJFCSYsYQ0bHkGll+hNM2JWsC1rxDmJJJQEmUy9g=
|
||||
github.com/bnb-chain/tss-lib/v2 v2.0.2/go.mod h1:s4LRfEqj89DhfNb+oraW0dURt5LtOHWXb9Gtkghn0L8=
|
||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||
github.com/btcsuite/btcd v0.23.4 h1:IzV6qqkfwbItOS/sg/aDfPDsjPP8twrCOE2R93hxMlQ=
|
||||
github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
|
||||
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
|
||||
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
|
||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
||||
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
|
||||
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 h1:l/lhv2aJCUignzls81+wvga0TFlyoZx8QxRMQgXpZik=
|
||||
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3/go.mod h1:AKpV6+wZ2MfPRJnTbQ6NPgWrKzbe9RCIlCF/FKzMtM8=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
|
||||
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
|
||||
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
|
||||
github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY=
|
||||
github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
|
||||
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
|
||||
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
|
||||
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
|
||||
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
|
||||
github.com/otiai10/jsonindent v0.0.0-20171116142732-447bf004320b/go.mod h1:SXIpH2WO0dyF5YBc6Iq8jc8TEJYe1Fk2Rc1EVYUdIgY=
|
||||
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
|
||||
github.com/otiai10/mint v1.3.2 h1:VYWnrP5fXmz1MXvjuUvcBrXSjGE6xjON+axB/UrpO3E=
|
||||
github.com/otiai10/mint v1.3.2/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
|
||||
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11 h1:7x5D/2dkkr27Tgh4WFuX+iCS6OzuE5YJoqJzeqM+5mc=
|
||||
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11/go.mod h1:1DmRMnU78i/OVkMnHzvhXSi4p8IhYUmtLJWhyOavJc0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||
github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
|
||||
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
|
||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
|
|
@ -0,0 +1,629 @@
|
|||
// Package tsslib provides TSS (Threshold Signature Scheme) functionality for Android
|
||||
// This package is designed to be compiled with gomobile for Android integration via JNI
|
||||
//
|
||||
// Based on the verified tss-party implementation from service-party-app (Electron version)
|
||||
package tsslib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bnb-chain/tss-lib/v2/common"
|
||||
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
|
||||
"github.com/bnb-chain/tss-lib/v2/ecdsa/signing"
|
||||
"github.com/bnb-chain/tss-lib/v2/tss"
|
||||
)
|
||||
|
||||
// MessageCallback is the interface for receiving TSS protocol messages
|
||||
// Android side implements this interface to handle message routing
|
||||
type MessageCallback interface {
|
||||
// OnOutgoingMessage is called when TSS needs to send a message to other parties
|
||||
// messageJSON contains: type, isBroadcast, toParties, payload (base64)
|
||||
OnOutgoingMessage(messageJSON string)
|
||||
|
||||
// OnProgress is called to report protocol progress
|
||||
OnProgress(round, totalRounds int)
|
||||
|
||||
// OnError is called when an error occurs
|
||||
OnError(errorMessage string)
|
||||
|
||||
// OnLog is called for debug logging
|
||||
OnLog(message string)
|
||||
}
|
||||
|
||||
// Participant represents a party in the TSS protocol
|
||||
type Participant struct {
|
||||
PartyID string `json:"partyId"`
|
||||
PartyIndex int `json:"partyIndex"`
|
||||
}
|
||||
|
||||
// tssSession manages a TSS keygen or signing session
|
||||
type tssSession struct {
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
callback MessageCallback
|
||||
localParty tss.Party
|
||||
partyIndexMap map[int]*tss.PartyID
|
||||
errCh chan error
|
||||
keygenResultCh chan *keygen.LocalPartySaveData
|
||||
signResultCh chan *common.SignatureData
|
||||
isKeygen bool
|
||||
}
|
||||
|
||||
var (
|
||||
currentSession *tssSession
|
||||
sessionMu sync.Mutex
|
||||
)
|
||||
|
||||
// StartKeygen initiates a new key generation session
|
||||
// This is the entry point called from Android via JNI
|
||||
func StartKeygen(
|
||||
sessionID, partyID string,
|
||||
partyIndex, thresholdT, thresholdN int,
|
||||
participantsJSON, password string,
|
||||
callback MessageCallback,
|
||||
) error {
|
||||
sessionMu.Lock()
|
||||
defer sessionMu.Unlock()
|
||||
|
||||
if currentSession != nil {
|
||||
return fmt.Errorf("a session is already in progress")
|
||||
}
|
||||
|
||||
// Parse participants
|
||||
var participants []Participant
|
||||
if err := json.Unmarshal([]byte(participantsJSON), &participants); err != nil {
|
||||
return fmt.Errorf("failed to parse participants: %w", err)
|
||||
}
|
||||
|
||||
if len(participants) != thresholdN {
|
||||
return fmt.Errorf("participant count mismatch: got %d, expected %d", len(participants), thresholdN)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
|
||||
session := &tssSession{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
callback: callback,
|
||||
partyIndexMap: make(map[int]*tss.PartyID),
|
||||
errCh: make(chan error, 1),
|
||||
keygenResultCh: make(chan *keygen.LocalPartySaveData, 1),
|
||||
isKeygen: true,
|
||||
}
|
||||
|
||||
// Create TSS party IDs - same as verified Electron version
|
||||
tssPartyIDs := make([]*tss.PartyID, len(participants))
|
||||
var selfTSSID *tss.PartyID
|
||||
|
||||
for i, p := range participants {
|
||||
partyKey := tss.NewPartyID(
|
||||
p.PartyID,
|
||||
fmt.Sprintf("party-%d", p.PartyIndex),
|
||||
big.NewInt(int64(p.PartyIndex+1)),
|
||||
)
|
||||
tssPartyIDs[i] = partyKey
|
||||
if p.PartyID == partyID {
|
||||
selfTSSID = partyKey
|
||||
}
|
||||
}
|
||||
|
||||
if selfTSSID == nil {
|
||||
cancel()
|
||||
return fmt.Errorf("self party not found in participants")
|
||||
}
|
||||
|
||||
// Sort party IDs
|
||||
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
|
||||
|
||||
// Build party index map for incoming messages
|
||||
for _, p := range sortedPartyIDs {
|
||||
for _, orig := range participants {
|
||||
if orig.PartyID == p.Id {
|
||||
session.partyIndexMap[orig.PartyIndex] = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create peer context and parameters
|
||||
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
|
||||
// User says "2-of-3" meaning 2 signers needed, so we pass (thresholdT-1) to TSS-lib
|
||||
// For 2-of-3: thresholdT=2, tss-lib threshold=1, signers_needed=1+1=2 ✓
|
||||
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||
tssThreshold := thresholdT - 1
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
|
||||
|
||||
callback.OnLog(fmt.Sprintf("[TSS-KEYGEN] NewParameters: partyCount=%d, tssThreshold=%d (from thresholdT=%d, means %d signers needed)",
|
||||
len(sortedPartyIDs), tssThreshold, thresholdT, thresholdT))
|
||||
|
||||
// Create channels
|
||||
outCh := make(chan tss.Message, thresholdN*10)
|
||||
endCh := make(chan *keygen.LocalPartySaveData, 1)
|
||||
|
||||
// Create local party
|
||||
localParty := keygen.NewLocalParty(params, outCh, endCh)
|
||||
session.localParty = localParty
|
||||
|
||||
// Start the local party
|
||||
go func() {
|
||||
if err := localParty.Start(); err != nil {
|
||||
session.errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle outgoing messages
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-outCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
session.handleOutgoingMessage(msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle completion
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
callback.OnError("session timeout or cancelled")
|
||||
case err := <-session.errCh:
|
||||
callback.OnError(fmt.Sprintf("keygen error: %v", err))
|
||||
case saveData := <-endCh:
|
||||
session.keygenResultCh <- saveData
|
||||
}
|
||||
}()
|
||||
|
||||
currentSession = session
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartSign initiates a new signing session
|
||||
// Based on verified executeSign from Electron version
|
||||
func StartSign(
|
||||
sessionID, partyID string,
|
||||
partyIndex, thresholdT, thresholdN int,
|
||||
participantsJSON, messageHashHex, shareDataBase64, password string,
|
||||
callback MessageCallback,
|
||||
) error {
|
||||
sessionMu.Lock()
|
||||
defer sessionMu.Unlock()
|
||||
|
||||
if currentSession != nil {
|
||||
return fmt.Errorf("a session is already in progress")
|
||||
}
|
||||
|
||||
// Parse participants
|
||||
var participants []Participant
|
||||
if err := json.Unmarshal([]byte(participantsJSON), &participants); err != nil {
|
||||
return fmt.Errorf("failed to parse participants: %w", err)
|
||||
}
|
||||
|
||||
// Note: For signing, participant count equals threshold T (not N)
|
||||
// because only T parties participate in signing
|
||||
if len(participants) != thresholdT {
|
||||
return fmt.Errorf("participant count mismatch: got %d, expected %d (threshold T)", len(participants), thresholdT)
|
||||
}
|
||||
|
||||
// Decode and decrypt share data
|
||||
encryptedShare, err := base64.StdEncoding.DecodeString(shareDataBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode share data: %w", err)
|
||||
}
|
||||
|
||||
shareBytes, err := decryptShare(encryptedShare, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt share: %w", err)
|
||||
}
|
||||
|
||||
// Parse keygen save data
|
||||
var keygenData keygen.LocalPartySaveData
|
||||
if err := json.Unmarshal(shareBytes, &keygenData); err != nil {
|
||||
return fmt.Errorf("failed to parse keygen data: %w", err)
|
||||
}
|
||||
|
||||
// Decode message hash
|
||||
messageHash, err := hex.DecodeString(messageHashHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode message hash: %w", err)
|
||||
}
|
||||
|
||||
if len(messageHash) != 32 {
|
||||
return fmt.Errorf("message hash must be 32 bytes, got %d", len(messageHash))
|
||||
}
|
||||
|
||||
msgBigInt := new(big.Int).SetBytes(messageHash)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
|
||||
session := &tssSession{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
callback: callback,
|
||||
partyIndexMap: make(map[int]*tss.PartyID),
|
||||
errCh: make(chan error, 1),
|
||||
signResultCh: make(chan *common.SignatureData, 1),
|
||||
isKeygen: false,
|
||||
}
|
||||
|
||||
// Create TSS party IDs for signing participants
|
||||
// IMPORTANT: For tss-lib signing, we must reconstruct the party IDs in the same way
|
||||
// as during keygen. The signing subset (T parties) must use their original keys from keygen.
|
||||
tssPartyIDs := make([]*tss.PartyID, 0, len(participants))
|
||||
var selfTSSID *tss.PartyID
|
||||
|
||||
for _, p := range participants {
|
||||
partyKey := tss.NewPartyID(
|
||||
p.PartyID,
|
||||
fmt.Sprintf("party-%d", p.PartyIndex),
|
||||
big.NewInt(int64(p.PartyIndex+1)),
|
||||
)
|
||||
tssPartyIDs = append(tssPartyIDs, partyKey)
|
||||
if p.PartyID == partyID {
|
||||
selfTSSID = partyKey
|
||||
}
|
||||
}
|
||||
|
||||
if selfTSSID == nil {
|
||||
cancel()
|
||||
return fmt.Errorf("self party not found in participants")
|
||||
}
|
||||
|
||||
// Sort party IDs (important for tss-lib)
|
||||
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
|
||||
|
||||
// Build party index map for incoming messages
|
||||
for _, p := range sortedPartyIDs {
|
||||
for _, orig := range participants {
|
||||
if orig.PartyID == p.Id {
|
||||
session.partyIndexMap[orig.PartyIndex] = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create peer context and parameters
|
||||
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
|
||||
// This MUST match keygen exactly!
|
||||
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||
tssThreshold := thresholdT - 1
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
|
||||
|
||||
callback.OnLog(fmt.Sprintf("[TSS-SIGN] NewParameters: partyCount=%d, tssThreshold=%d (from thresholdT=%d, means %d signers needed)",
|
||||
len(sortedPartyIDs), tssThreshold, thresholdT, thresholdT))
|
||||
|
||||
callback.OnLog(fmt.Sprintf("[TSS-SIGN] Original keygenData has %d parties (Ks length)", len(keygenData.Ks)))
|
||||
callback.OnLog(fmt.Sprintf("[TSS-SIGN] Building subset for %d signing parties", len(sortedPartyIDs)))
|
||||
|
||||
// CRITICAL: Build a subset of the keygen save data for the current signing parties
|
||||
// This is required when signing with a subset of the original keygen participants.
|
||||
subsetKeygenData := keygen.BuildLocalSaveDataSubset(keygenData, sortedPartyIDs)
|
||||
callback.OnLog(fmt.Sprintf("[TSS-SIGN] Subset keygenData has %d parties (Ks length)", len(subsetKeygenData.Ks)))
|
||||
|
||||
// Create channels
|
||||
outCh := make(chan tss.Message, thresholdT*10)
|
||||
endCh := make(chan *common.SignatureData, 1)
|
||||
|
||||
// Create local party for signing with the SUBSET keygen data
|
||||
localParty := signing.NewLocalParty(msgBigInt, params, subsetKeygenData, outCh, endCh)
|
||||
session.localParty = localParty
|
||||
|
||||
// Start the local party
|
||||
go func() {
|
||||
if err := localParty.Start(); err != nil {
|
||||
session.errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle outgoing messages
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-outCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
session.handleOutgoingMessage(msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle completion
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
callback.OnError("session timeout or cancelled")
|
||||
case err := <-session.errCh:
|
||||
callback.OnError(fmt.Sprintf("sign error: %v", err))
|
||||
case sigData := <-endCh:
|
||||
session.signResultCh <- sigData
|
||||
}
|
||||
}()
|
||||
|
||||
currentSession = session
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendIncomingMessage delivers a message from another party to the current session
|
||||
func SendIncomingMessage(fromPartyIndex int, isBroadcast bool, payloadBase64 string) error {
|
||||
sessionMu.Lock()
|
||||
session := currentSession
|
||||
sessionMu.Unlock()
|
||||
|
||||
if session == nil {
|
||||
return fmt.Errorf("no active session")
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
|
||||
fromParty, ok := session.partyIndexMap[fromPartyIndex]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown party index: %d", fromPartyIndex)
|
||||
}
|
||||
|
||||
payload, err := base64.StdEncoding.DecodeString(payloadBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode payload: %w", err)
|
||||
}
|
||||
|
||||
parsedMsg, err := tss.ParseWireMessage(payload, fromParty, isBroadcast)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse message: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, err := session.localParty.Update(parsedMsg)
|
||||
if err != nil {
|
||||
// Only send fatal errors
|
||||
if !isDuplicateError(err) {
|
||||
session.errCh <- err
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitForKeygenResult blocks until keygen completes and returns the result as JSON
|
||||
func WaitForKeygenResult(password string) (string, error) {
|
||||
sessionMu.Lock()
|
||||
session := currentSession
|
||||
sessionMu.Unlock()
|
||||
|
||||
if session == nil {
|
||||
return "", fmt.Errorf("no active session")
|
||||
}
|
||||
|
||||
if !session.isKeygen {
|
||||
return "", fmt.Errorf("current session is not a keygen session")
|
||||
}
|
||||
|
||||
// Track progress - GG20 keygen has 4 rounds
|
||||
totalRounds := 4
|
||||
|
||||
select {
|
||||
case <-session.ctx.Done():
|
||||
return "", session.ctx.Err()
|
||||
case saveData := <-session.keygenResultCh:
|
||||
// Keygen completed successfully
|
||||
session.callback.OnProgress(totalRounds, totalRounds)
|
||||
|
||||
// Get public key - same as Electron version
|
||||
pubKey := saveData.ECDSAPub.ToECDSAPubKey()
|
||||
pubKeyBytes := make([]byte, 33)
|
||||
pubKeyBytes[0] = 0x02 + byte(pubKey.Y.Bit(0))
|
||||
xBytes := pubKey.X.Bytes()
|
||||
copy(pubKeyBytes[33-len(xBytes):], xBytes)
|
||||
|
||||
// Serialize and encrypt save data
|
||||
saveDataBytes, err := json.Marshal(saveData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize save data: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt with password (same as Electron version)
|
||||
encryptedShare := encryptShare(saveDataBytes, password)
|
||||
|
||||
result := struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
EncryptedShare string `json:"encryptedShare"`
|
||||
}{
|
||||
PublicKey: base64.StdEncoding.EncodeToString(pubKeyBytes),
|
||||
EncryptedShare: base64.StdEncoding.EncodeToString(encryptedShare),
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
|
||||
// Clean up session
|
||||
session.cancel()
|
||||
sessionMu.Lock()
|
||||
currentSession = nil
|
||||
sessionMu.Unlock()
|
||||
|
||||
return string(resultJSON), nil
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForSignResult blocks until signing completes and returns the result as JSON
|
||||
func WaitForSignResult() (string, error) {
|
||||
sessionMu.Lock()
|
||||
session := currentSession
|
||||
sessionMu.Unlock()
|
||||
|
||||
if session == nil {
|
||||
return "", fmt.Errorf("no active session")
|
||||
}
|
||||
|
||||
if session.isKeygen {
|
||||
return "", fmt.Errorf("current session is not a sign session")
|
||||
}
|
||||
|
||||
// Track progress - GG20 signing has 9 rounds
|
||||
totalRounds := 9
|
||||
|
||||
select {
|
||||
case <-session.ctx.Done():
|
||||
return "", session.ctx.Err()
|
||||
case sigData := <-session.signResultCh:
|
||||
// Signing completed successfully
|
||||
session.callback.OnProgress(totalRounds, totalRounds)
|
||||
|
||||
// Construct signature: R (32 bytes) || S (32 bytes)
|
||||
rBytes := sigData.R
|
||||
sBytes := sigData.S
|
||||
|
||||
signature := make([]byte, 64)
|
||||
copy(signature[32-len(rBytes):32], rBytes)
|
||||
copy(signature[64-len(sBytes):64], sBytes)
|
||||
|
||||
// Recovery ID for Ethereum-style signatures
|
||||
recoveryID := int(sigData.SignatureRecovery[0])
|
||||
|
||||
// Append recovery ID to signature (r + s + v = 64 + 1 = 65 bytes)
|
||||
// This is needed for EVM transaction signing
|
||||
signatureWithV := make([]byte, 65)
|
||||
copy(signatureWithV, signature)
|
||||
signatureWithV[64] = byte(recoveryID)
|
||||
|
||||
result := struct {
|
||||
Signature string `json:"signature"`
|
||||
RecoveryID int `json:"recoveryId"`
|
||||
}{
|
||||
Signature: base64.StdEncoding.EncodeToString(signatureWithV),
|
||||
RecoveryID: recoveryID,
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
|
||||
// Clean up session
|
||||
session.cancel()
|
||||
sessionMu.Lock()
|
||||
currentSession = nil
|
||||
sessionMu.Unlock()
|
||||
|
||||
return string(resultJSON), nil
|
||||
}
|
||||
}
|
||||
|
||||
// CancelSession cancels the current session
|
||||
func CancelSession() {
|
||||
sessionMu.Lock()
|
||||
defer sessionMu.Unlock()
|
||||
|
||||
if currentSession != nil {
|
||||
currentSession.cancel()
|
||||
currentSession = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *tssSession) handleOutgoingMessage(msg tss.Message) {
|
||||
msgBytes, _, err := msg.WireBytes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var toParties []string
|
||||
if !msg.IsBroadcast() {
|
||||
for _, to := range msg.GetTo() {
|
||||
toParties = append(toParties, to.Id)
|
||||
}
|
||||
}
|
||||
|
||||
outMsg := struct {
|
||||
Type string `json:"type"`
|
||||
IsBroadcast bool `json:"isBroadcast"`
|
||||
ToParties []string `json:"toParties,omitempty"`
|
||||
Payload string `json:"payload"`
|
||||
}{
|
||||
Type: "outgoing",
|
||||
IsBroadcast: msg.IsBroadcast(),
|
||||
ToParties: toParties,
|
||||
Payload: base64.StdEncoding.EncodeToString(msgBytes),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(outMsg)
|
||||
s.callback.OnOutgoingMessage(string(data))
|
||||
}
|
||||
|
||||
func isDuplicateError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errStr := err.Error()
|
||||
return contains(errStr, "duplicate") || contains(errStr, "already received")
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// encryptShare encrypts the share data with password
|
||||
// Same implementation as Electron version for compatibility
|
||||
func encryptShare(data []byte, password string) []byte {
|
||||
// TODO: Use proper AES-256-GCM encryption
|
||||
// For now, just prepend a marker and the password hash
|
||||
// This is NOT secure - just a placeholder (same as Electron version)
|
||||
result := make([]byte, len(data)+32)
|
||||
copy(result[:32], hashPassword(password))
|
||||
copy(result[32:], data)
|
||||
return result
|
||||
}
|
||||
|
||||
// decryptShare decrypts the share data with password
|
||||
// Same implementation as Electron version for compatibility
|
||||
func decryptShare(encryptedData []byte, password string) ([]byte, error) {
|
||||
// Match the encryption format: first 32 bytes are password hash, rest is data
|
||||
if len(encryptedData) < 32 {
|
||||
return nil, fmt.Errorf("encrypted data too short")
|
||||
}
|
||||
|
||||
// Verify password (simple check - matches encryptShare)
|
||||
expectedHash := hashPassword(password)
|
||||
actualHash := encryptedData[:32]
|
||||
|
||||
// Simple comparison
|
||||
match := true
|
||||
for i := 0; i < 32; i++ {
|
||||
if expectedHash[i] != actualHash[i] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !match {
|
||||
return nil, fmt.Errorf("incorrect password")
|
||||
}
|
||||
|
||||
return encryptedData[32:], nil
|
||||
}
|
||||
|
||||
// hashPassword creates a simple hash of the password
|
||||
// Same implementation as Electron version for compatibility
|
||||
func hashPassword(password string) []byte {
|
||||
// Simple hash - should use PBKDF2 or Argon2 in production
|
||||
hash := make([]byte, 32)
|
||||
for i := 0; i < len(password) && i < 32; i++ {
|
||||
hash[i] = password[i]
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
|
@ -414,8 +414,8 @@ async function initServices() {
|
|||
}
|
||||
});
|
||||
|
||||
// 初始化 Kava 交易服务 (从数据库读取网络设置,默认测试网)
|
||||
const kavaNetwork = database.getSetting('kava_network') || 'testnet';
|
||||
// 初始化 Kava 交易服务 (从数据库读取网络设置,默认主网)
|
||||
const kavaNetwork = database.getSetting('kava_network') || 'mainnet';
|
||||
const kavaConfig = kavaNetwork === 'mainnet' ? KAVA_MAINNET_TX_CONFIG : KAVA_TESTNET_TX_CONFIG;
|
||||
kavaTxService = new KavaTxService(kavaConfig);
|
||||
debugLog.info('kava', `Kava network: ${kavaNetwork}`);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const navItems = [
|
|||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('testnet');
|
||||
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('mainnet');
|
||||
|
||||
const { environment, operation, checkAllServices, appReady } = useAppStore();
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export default function Settings() {
|
|||
autoBackup: false,
|
||||
backupPath: '',
|
||||
});
|
||||
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('testnet');
|
||||
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('mainnet');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export function getCurrentNetwork(): 'mainnet' | 'testnet' {
|
|||
return stored;
|
||||
}
|
||||
}
|
||||
return 'testnet'; // 默认测试网
|
||||
return 'mainnet'; // 默认主网
|
||||
}
|
||||
|
||||
export function getCurrentChainId(): number {
|
||||
|
|
|
|||
Loading…
Reference in New Issue