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:
hailin 2026-01-02 03:35:37 -08:00
parent 9f33e375d0
commit 47e4ef2b33
5 changed files with 345 additions and 14 deletions

View File

@ -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)
}

View File

@ -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
*/

View File

@ -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
)
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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
)