feat(android): add share export and import functionality
Add ability to backup wallet shares to files and restore from backups: - Add ShareBackup data class in Models.kt for backup format - Add exportShareBackup() and importShareBackup() in TssRepository - Add export/import state and methods in MainViewModel - Add file picker integration in MainActivity using ActivityResultContracts - Add import FAB button in WalletsScreen - Export saves as .tss-backup file with address and timestamp in filename - Import validates backup format and checks for duplicate wallets The backup file contains all necessary data to restore a wallet share: sessionId, publicKey, encryptedShare, threshold, partyIndex, address. 🤖 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
9f33e375d0
commit
47e4ef2b33
|
|
@ -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<Long?>(null) }
|
||||
|
||||
// Export/Import file handling
|
||||
val context = LocalContext.current
|
||||
var pendingExportJson by remember { mutableStateOf<String?>(null) }
|
||||
var pendingExportAddress by remember { mutableStateOf<String?>(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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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<ShareRecord> {
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ShareRecord?>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -917,6 +917,73 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// ========== Share Export/Import ==========
|
||||
|
||||
private val _exportResult = MutableStateFlow<ExportImportResult?>(null)
|
||||
val exportResult: StateFlow<ExportImportResult?> = _exportResult.asStateFlow()
|
||||
|
||||
private val _importResult = MutableStateFlow<ExportImportResult?>(null)
|
||||
val importResult: StateFlow<ExportImportResult?> = _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
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue