feat(android): 实现交易记录功能
- TssRepository 添加交易记录管理方法 (saveTransactionRecord, updateTransactionStatus, confirmTransaction, getTransactionRecords) - 添加历史交易同步功能 (syncERC20TransactionHistory, syncNativeTransactionHistory, syncAllTransactionHistory) - MainViewModel 添加交易记录状态和后台确认轮询 - 新建 TransactionHistoryScreen 交易记录列表界面 - WalletsScreen 添加"记录"按钮入口 - 转账成功后自动保存记录并后台确认状态 - 首次导入钱包时自动同步链上历史交易 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7b3d28c957
commit
3727b0e817
|
|
@ -110,6 +110,10 @@ fun TssPartyApp(
|
|||
val exportResult by viewModel.exportResult.collectAsState()
|
||||
val importResult by viewModel.importResult.collectAsState()
|
||||
|
||||
// Transaction history state
|
||||
val transactionRecords by viewModel.transactionRecords.collectAsState()
|
||||
val isSyncingHistory by viewModel.isSyncingHistory.collectAsState()
|
||||
|
||||
// Current transfer wallet
|
||||
var transferWalletId by remember { mutableStateOf<Long?>(null) }
|
||||
|
||||
|
|
@ -296,6 +300,9 @@ fun TssPartyApp(
|
|||
transferWalletId = shareId
|
||||
navController.navigate("transfer/$shareId")
|
||||
},
|
||||
onHistory = { shareId, address ->
|
||||
navController.navigate("history/$shareId/$address")
|
||||
},
|
||||
onExportBackup = { shareId, _ ->
|
||||
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] ========== onExportBackup called ==========")
|
||||
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] shareId: $shareId")
|
||||
|
|
@ -378,6 +385,27 @@ fun TssPartyApp(
|
|||
}
|
||||
}
|
||||
|
||||
// Transaction History Screen
|
||||
composable("history/{shareId}/{address}") { backStackEntry ->
|
||||
val shareId = backStackEntry.arguments?.getString("shareId")?.toLongOrNull() ?: 0L
|
||||
val address = backStackEntry.arguments?.getString("address") ?: ""
|
||||
|
||||
// Load records when entering screen
|
||||
LaunchedEffect(shareId) {
|
||||
viewModel.loadTransactionRecords(shareId)
|
||||
}
|
||||
|
||||
TransactionHistoryScreen(
|
||||
shareId = shareId,
|
||||
walletAddress = address,
|
||||
transactions = transactionRecords,
|
||||
networkType = settings.networkType,
|
||||
isSyncing = isSyncingHistory,
|
||||
onBack = { navController.popBackStack() },
|
||||
onRefresh = { viewModel.syncTransactionHistory(shareId, address) }
|
||||
)
|
||||
}
|
||||
|
||||
// Tab 2: Create Wallet (创建钱包)
|
||||
composable(BottomNavItem.Create.route) {
|
||||
CreateWalletScreen(
|
||||
|
|
|
|||
|
|
@ -2844,7 +2844,6 @@ class TssRepository @Inject constructor(
|
|||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating a keygen session
|
||||
|
|
@ -2976,6 +2975,478 @@ data class ParticipantStatusInfo(
|
|||
val status: String
|
||||
)
|
||||
|
||||
// ========== Transaction Record Methods ==========
|
||||
|
||||
/**
|
||||
* 保存交易记录
|
||||
* 转账发起后立即调用,初始状态为 PENDING
|
||||
*/
|
||||
suspend fun saveTransactionRecord(
|
||||
shareId: Long,
|
||||
fromAddress: String,
|
||||
toAddress: String,
|
||||
amount: String,
|
||||
tokenType: TokenType,
|
||||
txHash: String,
|
||||
gasPrice: String,
|
||||
direction: String = "SENT"
|
||||
): Long {
|
||||
return withContext(Dispatchers.IO) {
|
||||
android.util.Log.d("TssRepository", "[TX-RECORD] Saving transaction record: txHash=${txHash.take(20)}...")
|
||||
val entity = TransactionRecordEntity(
|
||||
shareId = shareId,
|
||||
fromAddress = fromAddress,
|
||||
toAddress = toAddress,
|
||||
amount = amount,
|
||||
tokenType = tokenType.name,
|
||||
txHash = txHash,
|
||||
gasPrice = gasPrice,
|
||||
status = "PENDING",
|
||||
direction = direction,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
val id = transactionRecordDao.insertRecord(entity)
|
||||
android.util.Log.d("TssRepository", "[TX-RECORD] Saved with id: $id")
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新交易状态
|
||||
* 确认交易后调用,更新状态为 CONFIRMED 或 FAILED
|
||||
*/
|
||||
suspend fun updateTransactionStatus(
|
||||
txHash: String,
|
||||
status: String,
|
||||
gasUsed: String,
|
||||
txFee: String,
|
||||
blockNumber: Long?,
|
||||
confirmedAt: Long?
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
android.util.Log.d("TssRepository", "[TX-RECORD] Updating status for txHash=${txHash.take(20)}... to $status")
|
||||
val record = transactionRecordDao.getRecordByTxHash(txHash)
|
||||
if (record != null) {
|
||||
transactionRecordDao.updateStatus(
|
||||
id = record.id,
|
||||
status = status,
|
||||
confirmedAt = confirmedAt,
|
||||
blockNumber = blockNumber,
|
||||
gasUsed = gasUsed,
|
||||
txFee = txFee
|
||||
)
|
||||
android.util.Log.d("TssRepository", "[TX-RECORD] Status updated successfully")
|
||||
} else {
|
||||
android.util.Log.w("TssRepository", "[TX-RECORD] Record not found for txHash=${txHash.take(20)}...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取钱包的交易记录列表
|
||||
*/
|
||||
fun getTransactionRecords(shareId: Long): Flow<List<TransactionRecordEntity>> {
|
||||
return transactionRecordDao.getRecordsForShare(shareId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有待确认的交易
|
||||
*/
|
||||
suspend fun getPendingTransactions(): List<TransactionRecordEntity> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
transactionRecordDao.getPendingRecords()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认交易状态
|
||||
* 查询链上交易收据并更新本地状态
|
||||
*/
|
||||
suspend fun confirmTransaction(txHash: String, rpcUrl: String): Result<Boolean> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
android.util.Log.d("TssRepository", "[TX-CONFIRM] Checking receipt for txHash=${txHash.take(20)}...")
|
||||
val receiptResult = TransactionUtils.getTransactionReceipt(txHash, rpcUrl)
|
||||
|
||||
receiptResult.fold(
|
||||
onSuccess = { receipt ->
|
||||
if (receipt != null) {
|
||||
val status = if (receipt.status) "CONFIRMED" else "FAILED"
|
||||
val gasUsed = receipt.gasUsed.toString()
|
||||
val blockNumber = try {
|
||||
java.math.BigInteger(receipt.blockNumber.removePrefix("0x"), 16).toLong()
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
// 计算交易费用 (gasUsed * gasPrice)
|
||||
val record = transactionRecordDao.getRecordByTxHash(txHash)
|
||||
val txFee = if (record != null && gasUsed.isNotEmpty()) {
|
||||
val gasUsedBig = java.math.BigInteger(gasUsed)
|
||||
val gasPriceBig = java.math.BigInteger(record.gasPrice)
|
||||
val feeWei = gasUsedBig.multiply(gasPriceBig)
|
||||
// 转换为 KAVA (18 decimals)
|
||||
val feeKava = feeWei.toBigDecimal().divide(java.math.BigDecimal("1000000000000000000"))
|
||||
feeKava.stripTrailingZeros().toPlainString()
|
||||
} else ""
|
||||
|
||||
updateTransactionStatus(
|
||||
txHash = txHash,
|
||||
status = status,
|
||||
gasUsed = gasUsed,
|
||||
txFee = txFee,
|
||||
blockNumber = blockNumber,
|
||||
confirmedAt = System.currentTimeMillis()
|
||||
)
|
||||
android.util.Log.d("TssRepository", "[TX-CONFIRM] Transaction $status: blockNumber=$blockNumber")
|
||||
Result.success(true)
|
||||
} else {
|
||||
android.util.Log.d("TssRepository", "[TX-CONFIRM] Receipt not yet available")
|
||||
Result.success(false) // 交易还未确认
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
android.util.Log.e("TssRepository", "[TX-CONFIRM] Error: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("TssRepository", "[TX-CONFIRM] Exception: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Transaction History Sync Methods ==========
|
||||
|
||||
/**
|
||||
* 同步 ERC-20 代币的历史交易
|
||||
* 使用 eth_getLogs 查询 Transfer 事件
|
||||
*/
|
||||
suspend fun syncERC20TransactionHistory(
|
||||
shareId: Long,
|
||||
address: String,
|
||||
contractAddress: String,
|
||||
tokenType: TokenType,
|
||||
rpcUrl: String
|
||||
): Result<Int> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
android.util.Log.d("TssRepository", "[SYNC-ERC20] Syncing ${tokenType.name} history for $address")
|
||||
|
||||
val client = okhttp3.OkHttpClient.Builder()
|
||||
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
// Transfer event signature: keccak256("Transfer(address,address,uint256)")
|
||||
val transferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||
|
||||
// 地址需要补齐到 32 字节(左侧补零)
|
||||
val paddedAddress = "0x" + address.removePrefix("0x").lowercase().padStart(64, '0')
|
||||
|
||||
var totalSynced = 0
|
||||
|
||||
// 查询发送的交易 (from = address)
|
||||
val sentRequest = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_getLogs",
|
||||
"params": [{
|
||||
"address": "$contractAddress",
|
||||
"topics": ["$transferTopic", "$paddedAddress", null],
|
||||
"fromBlock": "earliest",
|
||||
"toBlock": "latest"
|
||||
}],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val sentResponse = client.newCall(
|
||||
okhttp3.Request.Builder()
|
||||
.url(rpcUrl)
|
||||
.post(sentRequest.toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
).execute()
|
||||
|
||||
val sentBody = sentResponse.body?.string()
|
||||
if (sentBody != null) {
|
||||
totalSynced += parseAndSaveTransferLogs(sentBody, shareId, address, tokenType, "SENT")
|
||||
}
|
||||
|
||||
// 查询接收的交易 (to = address)
|
||||
val receivedRequest = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_getLogs",
|
||||
"params": [{
|
||||
"address": "$contractAddress",
|
||||
"topics": ["$transferTopic", null, "$paddedAddress"],
|
||||
"fromBlock": "earliest",
|
||||
"toBlock": "latest"
|
||||
}],
|
||||
"id": 2
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val receivedResponse = client.newCall(
|
||||
okhttp3.Request.Builder()
|
||||
.url(rpcUrl)
|
||||
.post(receivedRequest.toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
).execute()
|
||||
|
||||
val receivedBody = receivedResponse.body?.string()
|
||||
if (receivedBody != null) {
|
||||
totalSynced += parseAndSaveTransferLogs(receivedBody, shareId, address, tokenType, "RECEIVED")
|
||||
}
|
||||
|
||||
android.util.Log.d("TssRepository", "[SYNC-ERC20] Synced $totalSynced ${tokenType.name} transactions")
|
||||
Result.success(totalSynced)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("TssRepository", "[SYNC-ERC20] Error: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Transfer 日志并保存到数据库
|
||||
*/
|
||||
private suspend fun parseAndSaveTransferLogs(
|
||||
responseBody: String,
|
||||
shareId: Long,
|
||||
address: String,
|
||||
tokenType: TokenType,
|
||||
direction: String
|
||||
): Int {
|
||||
var count = 0
|
||||
try {
|
||||
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
|
||||
val result = json.getAsJsonArray("result") ?: return 0
|
||||
|
||||
for (log in result) {
|
||||
try {
|
||||
val logObj = log.asJsonObject
|
||||
val txHash = logObj.get("transactionHash")?.asString ?: continue
|
||||
val topics = logObj.getAsJsonArray("topics")
|
||||
val data = logObj.get("data")?.asString ?: "0x0"
|
||||
val blockNumber = logObj.get("blockNumber")?.asString
|
||||
|
||||
// 检查是否已存在
|
||||
val existing = transactionRecordDao.getRecordByTxHash(txHash)
|
||||
if (existing != null) continue
|
||||
|
||||
// 解析 from 和 to 地址 (topics[1] 和 topics[2])
|
||||
val fromAddress = if (topics.size() > 1) {
|
||||
"0x" + topics[1].asString.removePrefix("0x").takeLast(40)
|
||||
} else continue
|
||||
val toAddress = if (topics.size() > 2) {
|
||||
"0x" + topics[2].asString.removePrefix("0x").takeLast(40)
|
||||
} else continue
|
||||
|
||||
// 解析金额 (data 字段)
|
||||
val valueHex = data.removePrefix("0x")
|
||||
val valueBig = if (valueHex.isNotEmpty()) {
|
||||
java.math.BigInteger(valueHex, 16)
|
||||
} else java.math.BigInteger.ZERO
|
||||
|
||||
// 转换为人类可读格式 (根据代币精度)
|
||||
val decimals = TokenConfig.getDecimals(tokenType)
|
||||
val divisor = java.math.BigDecimal.TEN.pow(decimals)
|
||||
val humanReadable = valueBig.toBigDecimal().divide(divisor).stripTrailingZeros().toPlainString()
|
||||
|
||||
// 解析区块号
|
||||
val blockNum = blockNumber?.let {
|
||||
java.math.BigInteger(it.removePrefix("0x"), 16).toLong()
|
||||
}
|
||||
|
||||
// 保存记录
|
||||
val entity = TransactionRecordEntity(
|
||||
shareId = shareId,
|
||||
fromAddress = fromAddress,
|
||||
toAddress = toAddress,
|
||||
amount = humanReadable,
|
||||
tokenType = tokenType.name,
|
||||
txHash = txHash,
|
||||
gasPrice = "0", // 历史交易无法获取 gasPrice
|
||||
status = "CONFIRMED",
|
||||
direction = direction,
|
||||
blockNumber = blockNum,
|
||||
confirmedAt = System.currentTimeMillis()
|
||||
)
|
||||
transactionRecordDao.insertRecord(entity)
|
||||
count++
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("TssRepository", "[SYNC-ERC20] Error parsing log: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("TssRepository", "[SYNC-ERC20] Error parsing response: ${e.message}")
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步原生 KAVA 历史交易
|
||||
* 使用 KavaScan API
|
||||
*/
|
||||
suspend fun syncNativeTransactionHistory(
|
||||
shareId: Long,
|
||||
address: String,
|
||||
networkType: NetworkType = NetworkType.MAINNET
|
||||
): Result<Int> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
android.util.Log.d("TssRepository", "[SYNC-KAVA] Syncing native KAVA history for $address")
|
||||
|
||||
val baseUrl = if (networkType == NetworkType.TESTNET) {
|
||||
"https://testnet.kavascan.com"
|
||||
} else {
|
||||
"https://kavascan.com"
|
||||
}
|
||||
|
||||
val client = okhttp3.OkHttpClient.Builder()
|
||||
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
// KavaScan API 格式(类似 Etherscan)
|
||||
val apiUrl = "$baseUrl/api?module=account&action=txlist&address=$address&sort=desc"
|
||||
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url(apiUrl)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val responseBody = response.body?.string()
|
||||
|
||||
if (responseBody == null) {
|
||||
android.util.Log.w("TssRepository", "[SYNC-KAVA] Empty response from KavaScan")
|
||||
return@withContext Result.success(0)
|
||||
}
|
||||
|
||||
var count = 0
|
||||
try {
|
||||
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
|
||||
val status = json.get("status")?.asString
|
||||
val result = json.getAsJsonArray("result")
|
||||
|
||||
if (status == "1" && result != null) {
|
||||
for (tx in result) {
|
||||
try {
|
||||
val txObj = tx.asJsonObject
|
||||
val txHash = txObj.get("hash")?.asString ?: continue
|
||||
val fromAddr = txObj.get("from")?.asString ?: continue
|
||||
val toAddr = txObj.get("to")?.asString ?: continue
|
||||
val valueStr = txObj.get("value")?.asString ?: "0"
|
||||
val gasPrice = txObj.get("gasPrice")?.asString ?: "0"
|
||||
val gasUsed = txObj.get("gasUsed")?.asString ?: "0"
|
||||
val blockNum = txObj.get("blockNumber")?.asString?.toLongOrNull()
|
||||
val timestamp = txObj.get("timeStamp")?.asString?.toLongOrNull()?.times(1000)
|
||||
val isError = txObj.get("isError")?.asString == "1"
|
||||
|
||||
// 检查是否已存在
|
||||
val existing = transactionRecordDao.getRecordByTxHash(txHash)
|
||||
if (existing != null) continue
|
||||
|
||||
// 转换金额 (Wei -> KAVA, 18 decimals)
|
||||
val valueBig = java.math.BigInteger(valueStr)
|
||||
val valueKava = valueBig.toBigDecimal()
|
||||
.divide(java.math.BigDecimal("1000000000000000000"))
|
||||
.stripTrailingZeros()
|
||||
.toPlainString()
|
||||
|
||||
// 计算交易费
|
||||
val txFee = if (gasUsed.isNotEmpty() && gasPrice.isNotEmpty()) {
|
||||
val fee = java.math.BigInteger(gasUsed).multiply(java.math.BigInteger(gasPrice))
|
||||
fee.toBigDecimal()
|
||||
.divide(java.math.BigDecimal("1000000000000000000"))
|
||||
.stripTrailingZeros()
|
||||
.toPlainString()
|
||||
} else ""
|
||||
|
||||
// 判断方向
|
||||
val direction = if (fromAddr.equals(address, ignoreCase = true)) "SENT" else "RECEIVED"
|
||||
|
||||
val entity = TransactionRecordEntity(
|
||||
shareId = shareId,
|
||||
fromAddress = fromAddr,
|
||||
toAddress = toAddr,
|
||||
amount = valueKava,
|
||||
tokenType = TokenType.KAVA.name,
|
||||
txHash = txHash,
|
||||
gasPrice = gasPrice,
|
||||
gasUsed = gasUsed,
|
||||
txFee = txFee,
|
||||
status = if (isError) "FAILED" else "CONFIRMED",
|
||||
direction = direction,
|
||||
blockNumber = blockNum,
|
||||
createdAt = timestamp ?: System.currentTimeMillis(),
|
||||
confirmedAt = timestamp ?: System.currentTimeMillis()
|
||||
)
|
||||
transactionRecordDao.insertRecord(entity)
|
||||
count++
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("TssRepository", "[SYNC-KAVA] Error parsing tx: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("TssRepository", "[SYNC-KAVA] Error parsing response: ${e.message}")
|
||||
}
|
||||
|
||||
android.util.Log.d("TssRepository", "[SYNC-KAVA] Synced $count native KAVA transactions")
|
||||
Result.success(count)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("TssRepository", "[SYNC-KAVA] Error: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步所有类型的历史交易
|
||||
* 首次导入钱包时调用
|
||||
*/
|
||||
suspend fun syncAllTransactionHistory(
|
||||
shareId: Long,
|
||||
address: String,
|
||||
rpcUrl: String,
|
||||
networkType: NetworkType = NetworkType.MAINNET
|
||||
): Result<Int> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
android.util.Log.d("TssRepository", "[SYNC-ALL] Starting full transaction history sync for $address")
|
||||
var totalSynced = 0
|
||||
|
||||
// 同步 ERC-20 代币
|
||||
val tokenTypes = listOf(
|
||||
TokenType.GREEN_POINTS to GreenPointsToken.CONTRACT_ADDRESS,
|
||||
TokenType.ENERGY_POINTS to EnergyPointsToken.CONTRACT_ADDRESS,
|
||||
TokenType.FUTURE_POINTS to FuturePointsToken.CONTRACT_ADDRESS
|
||||
)
|
||||
|
||||
for ((tokenType, contractAddress) in tokenTypes) {
|
||||
syncERC20TransactionHistory(shareId, address, contractAddress, tokenType, rpcUrl)
|
||||
.onSuccess { count -> totalSynced += count }
|
||||
.onFailure { e ->
|
||||
android.util.Log.w("TssRepository", "[SYNC-ALL] Failed to sync ${tokenType.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 同步原生 KAVA
|
||||
syncNativeTransactionHistory(shareId, address, networkType)
|
||||
.onSuccess { count -> totalSynced += count }
|
||||
.onFailure { e ->
|
||||
android.util.Log.w("TssRepository", "[SYNC-ALL] Failed to sync native KAVA: ${e.message}")
|
||||
}
|
||||
|
||||
android.util.Log.d("TssRepository", "[SYNC-ALL] Completed: synced $totalSynced total transactions")
|
||||
Result.success(totalSynced)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ShareRecordEntity.toShareRecord() = ShareRecord(
|
||||
id = id,
|
||||
sessionId = sessionId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,386 @@
|
|||
package com.durian.tssparty.presentation.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
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.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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.durian.tssparty.data.local.TransactionRecordEntity
|
||||
import com.durian.tssparty.domain.model.EnergyPointsToken
|
||||
import com.durian.tssparty.domain.model.FuturePointsToken
|
||||
import com.durian.tssparty.domain.model.GreenPointsToken
|
||||
import com.durian.tssparty.domain.model.NetworkType
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TransactionHistoryScreen(
|
||||
shareId: Long,
|
||||
walletAddress: String,
|
||||
transactions: List<TransactionRecordEntity>,
|
||||
networkType: NetworkType,
|
||||
isSyncing: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("交易记录") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (isSyncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.padding(end = 8.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "刷新")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
// Wallet address header
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AccountBalanceWallet,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = walletAddress,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Transaction count
|
||||
Text(
|
||||
text = "共 ${transactions.size} 条记录",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (transactions.isEmpty()) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Receipt,
|
||||
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 = if (isSyncing) "正在同步中..." else "发起转账后将在此显示",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Transaction list
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(bottom = 16.dp)
|
||||
) {
|
||||
items(
|
||||
items = transactions.sortedByDescending { it.createdAt },
|
||||
key = { it.id }
|
||||
) { tx ->
|
||||
TransactionItemCard(
|
||||
transaction = tx,
|
||||
walletAddress = walletAddress,
|
||||
networkType = networkType,
|
||||
onClick = {
|
||||
// Open transaction in block explorer
|
||||
val explorerUrl = getExplorerUrl(networkType, tx.txHash)
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(explorerUrl))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransactionItemCard(
|
||||
transaction: TransactionRecordEntity,
|
||||
walletAddress: String,
|
||||
networkType: NetworkType,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val isSent = transaction.direction == "SENT" ||
|
||||
transaction.fromAddress.equals(walletAddress, ignoreCase = true)
|
||||
|
||||
val statusColor = when (transaction.status) {
|
||||
"CONFIRMED" -> Color(0xFF4CAF50) // Green
|
||||
"FAILED" -> MaterialTheme.colorScheme.error
|
||||
else -> Color(0xFFFF9800) // Orange for PENDING
|
||||
}
|
||||
|
||||
val tokenColor = when (transaction.tokenType) {
|
||||
"GREEN_POINTS" -> Color(0xFF4CAF50)
|
||||
"ENERGY_POINTS" -> Color(0xFF2196F3)
|
||||
"FUTURE_POINTS" -> Color(0xFF9C27B0)
|
||||
else -> MaterialTheme.colorScheme.primary // KAVA
|
||||
}
|
||||
|
||||
val tokenName = when (transaction.tokenType) {
|
||||
"GREEN_POINTS" -> GreenPointsToken.NAME
|
||||
"ENERGY_POINTS" -> EnergyPointsToken.NAME
|
||||
"FUTURE_POINTS" -> FuturePointsToken.NAME
|
||||
else -> "KAVA"
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
// Row 1: Direction icon + Amount + Status
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Direction icon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (isSent)
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
else
|
||||
Color(0xFFE8F5E9)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isSent) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward,
|
||||
contentDescription = if (isSent) "发送" else "接收",
|
||||
tint = if (isSent)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
Color(0xFF4CAF50),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Amount and token
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "${if (isSent) "-" else "+"}${transaction.amount}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isSent)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
Color(0xFF4CAF50)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = tokenName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = tokenColor
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (isSent) "发送" else "接收",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Status badge
|
||||
Surface(
|
||||
color = statusColor.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = when (transaction.status) {
|
||||
"CONFIRMED" -> "已确认"
|
||||
"FAILED" -> "失败"
|
||||
else -> "待确认"
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Row 2: Address (to/from)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = if (isSent) "发送至" else "来自",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = if (isSent) shortenAddress(transaction.toAddress) else shortenAddress(transaction.fromAddress),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = "时间",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = formatTimestamp(transaction.createdAt),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 3: Tx Hash (abbreviated)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "交易哈希: ${shortenTxHash(transaction.txHash)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
Icons.Default.OpenInNew,
|
||||
contentDescription = "查看详情",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
|
||||
// Row 4: Fee (if confirmed)
|
||||
if (transaction.status == "CONFIRMED" && transaction.txFee.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "手续费: ${transaction.txFee} KAVA",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortenAddress(address: String): String {
|
||||
return if (address.length > 16) {
|
||||
"${address.take(10)}...${address.takeLast(6)}"
|
||||
} else {
|
||||
address
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortenTxHash(txHash: String): String {
|
||||
return if (txHash.length > 20) {
|
||||
"${txHash.take(10)}...${txHash.takeLast(8)}"
|
||||
} else {
|
||||
txHash
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTimestamp(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
|
||||
private fun getExplorerUrl(networkType: NetworkType, txHash: String): String {
|
||||
return when (networkType) {
|
||||
NetworkType.MAINNET -> "https://kavascan.com/tx/$txHash"
|
||||
NetworkType.TESTNET -> "https://testnet.kavascan.com/tx/$txHash"
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ fun WalletsScreen(
|
|||
onDeleteShare: (Long) -> Unit,
|
||||
onRefreshBalance: ((String) -> Unit)? = null,
|
||||
onTransfer: ((shareId: Long) -> Unit)? = null,
|
||||
onHistory: ((shareId: Long, address: String) -> Unit)? = null,
|
||||
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
|
||||
onImportBackup: (() -> Unit)? = null,
|
||||
onCreateWallet: (() -> Unit)? = null
|
||||
|
|
@ -157,6 +158,9 @@ fun WalletsScreen(
|
|||
onTransfer = {
|
||||
onTransfer?.invoke(share.id)
|
||||
},
|
||||
onHistory = {
|
||||
onHistory?.invoke(share.id, share.address)
|
||||
},
|
||||
onDelete = { onDeleteShare(share.id) }
|
||||
)
|
||||
}
|
||||
|
|
@ -225,6 +229,7 @@ private fun WalletItemCard(
|
|||
walletBalance: WalletBalance? = null,
|
||||
onViewDetails: () -> Unit,
|
||||
onTransfer: () -> Unit,
|
||||
onHistory: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -435,6 +440,16 @@ private fun WalletItemCard(
|
|||
Text("转账")
|
||||
}
|
||||
|
||||
TextButton(onClick = onHistory) {
|
||||
Icon(
|
||||
Icons.Default.Receipt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("记录")
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { showDeleteDialog = true },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package com.durian.tssparty.presentation.viewmodel
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.durian.tssparty.data.repository.JoinKeygenViaGrpcResult
|
||||
import com.durian.tssparty.data.repository.TssRepository
|
||||
import com.durian.tssparty.data.repository.TssRepository.JoinKeygenViaGrpcResult
|
||||
import com.durian.tssparty.domain.model.*
|
||||
import com.durian.tssparty.util.AddressUtils
|
||||
import com.durian.tssparty.util.TransactionUtils
|
||||
|
|
@ -917,6 +917,66 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// ========== Transaction Records ==========
|
||||
|
||||
private val _transactionRecords = MutableStateFlow<List<com.durian.tssparty.data.local.TransactionRecordEntity>>(emptyList())
|
||||
val transactionRecords: StateFlow<List<com.durian.tssparty.data.local.TransactionRecordEntity>> = _transactionRecords.asStateFlow()
|
||||
|
||||
private val _isSyncingHistory = MutableStateFlow(false)
|
||||
val isSyncingHistory: StateFlow<Boolean> = _isSyncingHistory.asStateFlow()
|
||||
|
||||
/**
|
||||
* 加载钱包的交易记录
|
||||
*/
|
||||
fun loadTransactionRecords(shareId: Long) {
|
||||
viewModelScope.launch {
|
||||
repository.getTransactionRecords(shareId).collect { records ->
|
||||
_transactionRecords.value = records
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步钱包的所有历史交易
|
||||
* 首次导入钱包时调用
|
||||
*/
|
||||
fun syncTransactionHistory(shareId: Long, address: String) {
|
||||
viewModelScope.launch {
|
||||
_isSyncingHistory.value = true
|
||||
android.util.Log.d("MainViewModel", "[SYNC] Starting transaction history sync for $address")
|
||||
|
||||
val rpcUrl = _settings.value.kavaRpcUrl
|
||||
val networkType = _settings.value.networkType
|
||||
|
||||
val result = repository.syncAllTransactionHistory(shareId, address, rpcUrl, networkType)
|
||||
result.fold(
|
||||
onSuccess = { count ->
|
||||
android.util.Log.d("MainViewModel", "[SYNC] Synced $count transactions")
|
||||
},
|
||||
onFailure = { e ->
|
||||
android.util.Log.e("MainViewModel", "[SYNC] Error syncing: ${e.message}")
|
||||
}
|
||||
)
|
||||
_isSyncingHistory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认所有待处理的交易
|
||||
* 应用启动时调用
|
||||
*/
|
||||
fun confirmPendingTransactions() {
|
||||
viewModelScope.launch {
|
||||
val rpcUrl = _settings.value.kavaRpcUrl
|
||||
val pendingRecords = repository.getPendingTransactions()
|
||||
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Found ${pendingRecords.size} pending transactions")
|
||||
|
||||
for (record in pendingRecords) {
|
||||
repository.confirmTransaction(record.txHash, rpcUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Share Export/Import ==========
|
||||
|
||||
private val _exportResult = MutableStateFlow<ExportImportResult?>(null)
|
||||
|
|
@ -991,6 +1051,10 @@ class MainViewModel @Inject constructor(
|
|||
// Fetch balance for the imported wallet
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] Fetching balance...")
|
||||
fetchBalanceForShare(share)
|
||||
|
||||
// Sync transaction history from blockchain (first-time import)
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] Starting transaction history sync...")
|
||||
syncTransactionHistory(share.id, share.address)
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] Import complete!")
|
||||
},
|
||||
onFailure = { e ->
|
||||
|
|
@ -1390,6 +1454,23 @@ class MainViewModel @Inject constructor(
|
|||
onSuccess = { hash ->
|
||||
android.util.Log.d("MainViewModel", "[BROADCAST] SUCCESS! txHash=$hash")
|
||||
_txHash.value = hash
|
||||
|
||||
// 保存交易记录到本地数据库
|
||||
val state = _transferState.value
|
||||
android.util.Log.d("MainViewModel", "[BROADCAST] Saving transaction record: shareId=${state.shareId}, tokenType=${state.tokenType}")
|
||||
repository.saveTransactionRecord(
|
||||
shareId = state.shareId,
|
||||
fromAddress = tx.from,
|
||||
toAddress = tx.to,
|
||||
amount = state.amount,
|
||||
tokenType = state.tokenType,
|
||||
txHash = hash,
|
||||
gasPrice = tx.gasPrice.toString()
|
||||
)
|
||||
|
||||
// 启动后台确认交易状态
|
||||
confirmTransactionInBackground(hash, rpcUrl)
|
||||
|
||||
_uiState.update { it.copy(isLoading = false, successMessage = "交易已广播!") }
|
||||
},
|
||||
onFailure = { e ->
|
||||
|
|
@ -1400,6 +1481,37 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台确认交易状态
|
||||
* 每 3 秒轮询一次,最多尝试 60 次(3 分钟)
|
||||
*/
|
||||
private fun confirmTransactionInBackground(txHash: String, rpcUrl: String) {
|
||||
viewModelScope.launch {
|
||||
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Starting background confirmation for $txHash")
|
||||
var attempts = 0
|
||||
val maxAttempts = 60
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
kotlinx.coroutines.delay(3000) // 等待 3 秒
|
||||
attempts++
|
||||
|
||||
val result = repository.confirmTransaction(txHash, rpcUrl)
|
||||
result.fold(
|
||||
onSuccess = { confirmed ->
|
||||
if (confirmed) {
|
||||
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Transaction confirmed after $attempts attempts")
|
||||
return@launch
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
android.util.Log.w("MainViewModel", "[TX-CONFIRM] Error checking confirmation: ${e.message}")
|
||||
}
|
||||
)
|
||||
}
|
||||
android.util.Log.w("MainViewModel", "[TX-CONFIRM] Max attempts reached, transaction may still be pending")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset transfer state
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue