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 交易服务 (从数据库读取网络设置,默认测试网)
|
// 初始化 Kava 交易服务 (从数据库读取网络设置,默认主网)
|
||||||
const kavaNetwork = database.getSetting('kava_network') || 'testnet';
|
const kavaNetwork = database.getSetting('kava_network') || 'mainnet';
|
||||||
const kavaConfig = kavaNetwork === 'mainnet' ? KAVA_MAINNET_TX_CONFIG : KAVA_TESTNET_TX_CONFIG;
|
const kavaConfig = kavaNetwork === 'mainnet' ? KAVA_MAINNET_TX_CONFIG : KAVA_TESTNET_TX_CONFIG;
|
||||||
kavaTxService = new KavaTxService(kavaConfig);
|
kavaTxService = new KavaTxService(kavaConfig);
|
||||||
debugLog.info('kava', `Kava network: ${kavaNetwork}`);
|
debugLog.info('kava', `Kava network: ${kavaNetwork}`);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const navItems = [
|
||||||
export default function Layout({ children }: LayoutProps) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
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();
|
const { environment, operation, checkAllServices, appReady } = useAppStore();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export default function Settings() {
|
||||||
autoBackup: false,
|
autoBackup: false,
|
||||||
backupPath: '',
|
backupPath: '',
|
||||||
});
|
});
|
||||||
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('testnet');
|
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('mainnet');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export function getCurrentNetwork(): 'mainnet' | 'testnet' {
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'testnet'; // 默认测试网
|
return 'mainnet'; // 默认主网
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentChainId(): number {
|
export function getCurrentChainId(): number {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue