diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt index 8d093ef1..3ac592fc 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt @@ -3,20 +3,25 @@ package com.durian.tssparty import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.net.Uri import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext 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.domain.model.ShareBackup import com.durian.tssparty.domain.model.TokenType import com.durian.tssparty.presentation.components.BottomNavItem import com.durian.tssparty.presentation.components.TssBottomNavigation @@ -25,6 +30,9 @@ 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 +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -97,9 +105,80 @@ fun TssPartyApp( val accountServiceTestResult by viewModel.accountServiceTestResult.collectAsState() val kavaApiTestResult by viewModel.kavaApiTestResult.collectAsState() + // Export/Import state + val exportResult by viewModel.exportResult.collectAsState() + val importResult by viewModel.importResult.collectAsState() + // Current transfer wallet var transferWalletId by remember { mutableStateOf(null) } + // Export/Import file handling + val context = LocalContext.current + var pendingExportJson by remember { mutableStateOf(null) } + var pendingExportAddress by remember { mutableStateOf(null) } + + // File picker for saving backup + val createDocumentLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(ShareBackup.MIME_TYPE) + ) { uri: Uri? -> + uri?.let { targetUri -> + pendingExportJson?.let { json -> + try { + context.contentResolver.openOutputStream(targetUri)?.use { outputStream -> + outputStream.write(json.toByteArray(Charsets.UTF_8)) + } + Toast.makeText(context, "备份文件已保存", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(context, "保存失败: ${e.message}", Toast.LENGTH_LONG).show() + } + pendingExportJson = null + pendingExportAddress = null + } + } + } + + // File picker for importing backup + val openDocumentLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + uri?.let { sourceUri -> + try { + context.contentResolver.openInputStream(sourceUri)?.use { inputStream -> + val json = inputStream.bufferedReader().readText() + viewModel.importShareBackup(json) + } + } catch (e: Exception) { + Toast.makeText(context, "读取文件失败: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + + // Handle export result - trigger file save dialog + LaunchedEffect(pendingExportJson) { + pendingExportJson?.let { json -> + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val addressSuffix = pendingExportAddress?.take(8) ?: "wallet" + val fileName = "tss_backup_${addressSuffix}_$timestamp.${ShareBackup.FILE_EXTENSION}" + createDocumentLauncher.launch(fileName) + } + } + + // Handle import result - show toast + LaunchedEffect(importResult) { + importResult?.let { result -> + when { + result.isSuccess -> { + Toast.makeText(context, result.message ?: "导入成功", Toast.LENGTH_SHORT).show() + viewModel.clearExportImportResult() + } + result.error != null -> { + Toast.makeText(context, result.error, Toast.LENGTH_LONG).show() + viewModel.clearExportImportResult() + } + } + } + } + // Track if startup is complete var startupComplete by remember { mutableStateOf(false) } @@ -177,6 +256,19 @@ fun TssPartyApp( transferWalletId = shareId navController.navigate("transfer/$shareId") }, + onExportBackup = { shareId, _ -> + // Get address for filename + val share = shares.find { it.id == shareId } + pendingExportAddress = share?.address + // Export and save to file + viewModel.exportShareBackup(shareId) { json -> + pendingExportJson = json + } + }, + onImportBackup = { + // Open file picker to select backup file + openDocumentLauncher.launch(arrayOf("*/*")) + }, onCreateWallet = { navController.navigate(BottomNavItem.Create.route) } diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt index 05472f56..cd3b9fe6 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt @@ -1859,6 +1859,74 @@ class TssRepository @Inject constructor( shareRecordDao.deleteShareById(id) } + /** + * Export a share as backup JSON string + * @param shareId The ID of the share to export + * @return Result containing the backup JSON string + */ + suspend fun exportShareBackup(shareId: Long): Result { + return withContext(Dispatchers.IO) { + try { + val share = shareRecordDao.getShareById(shareId) + ?: return@withContext Result.failure(Exception("钱包不存在")) + + val backup = ShareBackup.fromShareRecord(share.toShareRecord()) + val json = com.google.gson.Gson().toJson(backup) + + android.util.Log.d("TssRepository", "Exported share backup for address: ${share.address}") + Result.success(json) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Failed to export share backup", e) + Result.failure(e) + } + } + } + + /** + * Import a share from backup JSON string + * @param backupJson The backup JSON string to import + * @return Result containing the imported ShareRecord + */ + suspend fun importShareBackup(backupJson: String): Result { + return withContext(Dispatchers.IO) { + try { + val backup = com.google.gson.Gson().fromJson(backupJson, ShareBackup::class.java) + ?: return@withContext Result.failure(Exception("无效的备份文件格式")) + + // Check if wallet already exists + val existingShare = shareRecordDao.getShareByAddress(backup.address) + if (existingShare != null) { + return@withContext Result.failure(Exception("此钱包已存在 (地址: ${backup.address.take(10)}...)")) + } + + // Convert to entity and save + val shareRecord = backup.toShareRecord() + val entity = ShareRecordEntity( + sessionId = shareRecord.sessionId, + publicKey = shareRecord.publicKey, + encryptedShare = shareRecord.encryptedShare, + thresholdT = shareRecord.thresholdT, + thresholdN = shareRecord.thresholdN, + partyIndex = shareRecord.partyIndex, + address = shareRecord.address, + createdAt = shareRecord.createdAt + ) + + val newId = shareRecordDao.insertShare(entity) + val savedShare = shareRecord.copy(id = newId) + + android.util.Log.d("TssRepository", "Imported share backup for address: ${backup.address}") + Result.success(savedShare) + } catch (e: com.google.gson.JsonSyntaxException) { + android.util.Log.e("TssRepository", "Invalid JSON format in backup", e) + Result.failure(Exception("备份文件格式错误")) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Failed to import share backup", e) + Result.failure(e) + } + } + } + /** * Get balance for an address using eth_getBalance RPC */ diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt index 4232ab7f..4b2bfc85 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt @@ -158,3 +158,77 @@ data class WalletBalance( val kavaBalance: String = "0", // Native KAVA balance val greenPointsBalance: String = "0" // 绿积分 (dUSDT) balance ) + +/** + * Share backup data for export/import + * Contains all necessary information to restore a wallet share + */ +data class ShareBackup( + @SerializedName("version") + val version: Int = 1, // Backup format version for future compatibility + + @SerializedName("sessionId") + val sessionId: String, + + @SerializedName("publicKey") + val publicKey: String, // base64 encoded + + @SerializedName("encryptedShare") + val encryptedShare: String, // base64 encoded, encrypted with user password + + @SerializedName("thresholdT") + val thresholdT: Int, + + @SerializedName("thresholdN") + val thresholdN: Int, + + @SerializedName("partyIndex") + val partyIndex: Int, + + @SerializedName("address") + val address: String, + + @SerializedName("createdAt") + val createdAt: Long, + + @SerializedName("exportedAt") + val exportedAt: Long = System.currentTimeMillis() +) { + companion object { + const val FILE_EXTENSION = "tss-backup" + const val MIME_TYPE = "application/octet-stream" + + /** + * Create backup from ShareRecord + */ + fun fromShareRecord(share: ShareRecord): ShareBackup { + return ShareBackup( + sessionId = share.sessionId, + publicKey = share.publicKey, + encryptedShare = share.encryptedShare, + thresholdT = share.thresholdT, + thresholdN = share.thresholdN, + partyIndex = share.partyIndex, + address = share.address, + createdAt = share.createdAt + ) + } + } + + /** + * Convert backup to ShareRecord for database storage + */ + fun toShareRecord(): ShareRecord { + return ShareRecord( + id = 0, // Will be auto-generated + sessionId = sessionId, + publicKey = publicKey, + encryptedShare = encryptedShare, + thresholdT = thresholdT, + thresholdN = thresholdN, + partyIndex = partyIndex, + address = address, + createdAt = createdAt + ) + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt index 983b4db4..cad45ef4 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt @@ -56,6 +56,7 @@ fun WalletsScreen( onRefreshBalance: ((String) -> Unit)? = null, onTransfer: ((shareId: Long) -> Unit)? = null, onExportBackup: ((shareId: Long, password: String) -> Unit)? = null, + onImportBackup: (() -> Unit)? = null, onCreateWallet: (() -> Unit)? = null ) { var selectedWallet by remember { mutableStateOf(null) } @@ -161,20 +162,39 @@ fun WalletsScreen( } } - // 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 - ) + // Floating Action Buttons - Import and Create + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Import button (smaller, secondary) + if (onImportBackup != null) { + FloatingActionButton( + onClick = onImportBackup, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) { + Icon( + imageVector = Icons.Default.Upload, + contentDescription = "导入备份" + ) + } + } + + // Create wallet button (primary) + if (onCreateWallet != null) { + FloatingActionButton( + onClick = onCreateWallet, + containerColor = MaterialTheme.colorScheme.primary + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "创建钱包", + tint = MaterialTheme.colorScheme.onPrimary + ) + } } } } diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt index 7f095a35..d40d38fa 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt @@ -917,6 +917,73 @@ class MainViewModel @Inject constructor( } } + // ========== Share Export/Import ========== + + private val _exportResult = MutableStateFlow(null) + val exportResult: StateFlow = _exportResult.asStateFlow() + + private val _importResult = MutableStateFlow(null) + val importResult: StateFlow = _importResult.asStateFlow() + + /** + * Export a share backup + * @param shareId The ID of the share to export + * @return The backup JSON string on success + */ + fun exportShareBackup(shareId: Long, onSuccess: (String) -> Unit) { + viewModelScope.launch { + _exportResult.value = ExportImportResult(isLoading = true) + + val result = repository.exportShareBackup(shareId) + result.fold( + onSuccess = { json -> + _exportResult.value = ExportImportResult(isSuccess = true) + onSuccess(json) + }, + onFailure = { e -> + _exportResult.value = ExportImportResult(error = e.message ?: "导出失败") + } + ) + } + } + + /** + * Import a share from backup JSON + * @param backupJson The backup JSON string to import + */ + fun importShareBackup(backupJson: String) { + viewModelScope.launch { + _importResult.value = ExportImportResult(isLoading = true) + + val result = repository.importShareBackup(backupJson) + result.fold( + onSuccess = { share -> + _importResult.value = ExportImportResult( + isSuccess = true, + message = "已成功导入钱包 (${share.address.take(10)}...)" + ) + // Update wallet count + _appState.update { state -> + state.copy(walletCount = state.walletCount + 1) + } + // Fetch balance for the imported wallet + fetchBalanceForShare(share) + }, + onFailure = { e -> + _importResult.value = ExportImportResult(error = e.message ?: "导入失败") + } + ) + } + } + + /** + * Clear export/import result state + */ + fun clearExportImportResult() { + _exportResult.value = null + _importResult.value = null + } + /** * Update settings */ @@ -1446,3 +1513,13 @@ data class PendingSignInitiatorInfo( val shareId: Long, val password: String ) + +/** + * Result of export/import operation + */ +data class ExportImportResult( + val isLoading: Boolean = false, + val isSuccess: Boolean = false, + val message: String? = null, + val error: String? = null +)