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.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.durian.tssparty.domain.model.AppReadyState
|
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.domain.model.TokenType
|
||||||
import com.durian.tssparty.presentation.components.BottomNavItem
|
import com.durian.tssparty.presentation.components.BottomNavItem
|
||||||
import com.durian.tssparty.presentation.components.TssBottomNavigation
|
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.presentation.viewmodel.ConnectionTestResult as ViewModelConnectionTestResult
|
||||||
import com.durian.tssparty.ui.theme.TssPartyTheme
|
import com.durian.tssparty.ui.theme.TssPartyTheme
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
@ -97,9 +105,80 @@ fun TssPartyApp(
|
||||||
val accountServiceTestResult by viewModel.accountServiceTestResult.collectAsState()
|
val accountServiceTestResult by viewModel.accountServiceTestResult.collectAsState()
|
||||||
val kavaApiTestResult by viewModel.kavaApiTestResult.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
|
// Current transfer wallet
|
||||||
var transferWalletId by remember { mutableStateOf<Long?>(null) }
|
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
|
// Track if startup is complete
|
||||||
var startupComplete by remember { mutableStateOf(false) }
|
var startupComplete by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
|
@ -177,6 +256,19 @@ fun TssPartyApp(
|
||||||
transferWalletId = shareId
|
transferWalletId = shareId
|
||||||
navController.navigate("transfer/$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 = {
|
onCreateWallet = {
|
||||||
navController.navigate(BottomNavItem.Create.route)
|
navController.navigate(BottomNavItem.Create.route)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1859,6 +1859,74 @@ class TssRepository @Inject constructor(
|
||||||
shareRecordDao.deleteShareById(id)
|
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
|
* Get balance for an address using eth_getBalance RPC
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -158,3 +158,77 @@ data class WalletBalance(
|
||||||
val kavaBalance: String = "0", // Native KAVA balance
|
val kavaBalance: String = "0", // Native KAVA balance
|
||||||
val greenPointsBalance: String = "0" // 绿积分 (dUSDT) 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,
|
onRefreshBalance: ((String) -> Unit)? = null,
|
||||||
onTransfer: ((shareId: Long) -> Unit)? = null,
|
onTransfer: ((shareId: Long) -> Unit)? = null,
|
||||||
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
|
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
|
||||||
|
onImportBackup: (() -> Unit)? = null,
|
||||||
onCreateWallet: (() -> Unit)? = null
|
onCreateWallet: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
var selectedWallet by remember { mutableStateOf<ShareRecord?>(null) }
|
var selectedWallet by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
|
|
@ -161,20 +162,39 @@ fun WalletsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating Action Button for creating wallet
|
// Floating Action Buttons - Import and Create
|
||||||
if (onCreateWallet != null) {
|
Column(
|
||||||
FloatingActionButton(
|
modifier = Modifier
|
||||||
onClick = onCreateWallet,
|
.align(Alignment.BottomEnd)
|
||||||
modifier = Modifier
|
.padding(16.dp),
|
||||||
.align(Alignment.BottomEnd)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
.padding(16.dp),
|
) {
|
||||||
containerColor = MaterialTheme.colorScheme.primary
|
// Import button (smaller, secondary)
|
||||||
) {
|
if (onImportBackup != null) {
|
||||||
Icon(
|
FloatingActionButton(
|
||||||
imageVector = Icons.Default.Add,
|
onClick = onImportBackup,
|
||||||
contentDescription = "创建钱包",
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
tint = MaterialTheme.colorScheme.onPrimary
|
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
|
* Update settings
|
||||||
*/
|
*/
|
||||||
|
|
@ -1446,3 +1513,13 @@ data class PendingSignInitiatorInfo(
|
||||||
val shareId: Long,
|
val shareId: Long,
|
||||||
val password: String
|
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