修正编辑弹窗无法打开的问题
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package cn.airnan.a2fair.core
|
||||
|
||||
import android.net.Uri
|
||||
import cn.airnan.a2fair.data.entity.HashAlgorithm
|
||||
import cn.airnan.a2fair.data.entity.Token
|
||||
import cn.airnan.a2fair.data.entity.TokenType
|
||||
|
||||
object OtpUriBuilder {
|
||||
|
||||
fun build(token: Token): String {
|
||||
val type = when (token.tokenType) {
|
||||
TokenType.TOTP -> "totp"
|
||||
TokenType.HOTP -> "hotp"
|
||||
TokenType.STEAM -> "totp"
|
||||
}
|
||||
|
||||
val label = if (token.issuer.isNotBlank()) {
|
||||
"${Uri.encode(token.issuer)}:${Uri.encode(token.serviceName)}"
|
||||
} else {
|
||||
Uri.encode(token.serviceName)
|
||||
}
|
||||
|
||||
val uri = Uri.Builder()
|
||||
.scheme("otpauth")
|
||||
.authority(type)
|
||||
.path("/$label")
|
||||
.appendQueryParameter("secret", token.secret.value)
|
||||
.appendQueryParameter("digits", token.digits.toString())
|
||||
.appendQueryParameter("period", token.period.toString())
|
||||
|
||||
if (token.issuer.isNotBlank()) {
|
||||
uri.appendQueryParameter("issuer", token.issuer)
|
||||
}
|
||||
|
||||
when (token.algorithm) {
|
||||
HashAlgorithm.SHA1 -> { /* default */ }
|
||||
else -> uri.appendQueryParameter("algorithm", token.algorithm.name)
|
||||
}
|
||||
|
||||
if (token.tokenType == TokenType.HOTP) {
|
||||
uri.appendQueryParameter("counter", token.counter.toString())
|
||||
}
|
||||
|
||||
return uri.build().toString()
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cn.airnan.a2fair.R
|
||||
import cn.airnan.a2fair.data.entity.HashAlgorithm
|
||||
import cn.airnan.a2fair.data.entity.TokenType
|
||||
@@ -52,9 +53,10 @@ fun AddTokenBottomSheet(
|
||||
viewModel: TokensViewModel,
|
||||
onDismissRequest: () -> Unit
|
||||
) {
|
||||
val state by viewModel.addTokenState.collectAsState()
|
||||
val state by viewModel.addTokenState.collectAsStateWithLifecycle()
|
||||
val isEditing = viewModel.isEditing
|
||||
val context = LocalContext.current
|
||||
var showManualEntry by remember { mutableStateOf(false) }
|
||||
var showManualEntry by remember { mutableStateOf(isEditing) }
|
||||
var showCameraScanner by remember { mutableStateOf(false) }
|
||||
|
||||
val photoPickerLauncher = rememberLauncherForActivityResult(
|
||||
@@ -113,7 +115,11 @@ fun AddTokenBottomSheet(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = if (showManualEntry) stringResource(R.string.enter_details_manually) else stringResource(R.string.add_token_title),
|
||||
text = when {
|
||||
isEditing -> stringResource(R.string.edit_token)
|
||||
showManualEntry -> stringResource(R.string.enter_details_manually)
|
||||
else -> stringResource(R.string.add_token_title)
|
||||
},
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
@@ -155,7 +161,12 @@ fun AddTokenBottomSheet(
|
||||
ManualEntryForm(
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
onCancel = { showManualEntry = false }
|
||||
onCancel = if (isEditing) {
|
||||
{ viewModel.resetAddState(); onDismissRequest() }
|
||||
} else {
|
||||
{ showManualEntry = false }
|
||||
},
|
||||
isEditing = isEditing
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -177,7 +188,8 @@ fun AddTokenBottomSheet(
|
||||
fun ManualEntryForm(
|
||||
state: AddTokenUiState,
|
||||
viewModel: TokensViewModel,
|
||||
onCancel: () -> Unit
|
||||
onCancel: () -> Unit,
|
||||
isEditing: Boolean = false
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.accountName,
|
||||
@@ -339,11 +351,17 @@ fun ManualEntryForm(
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
if (!state.isDuplicate) {
|
||||
if (isEditing) {
|
||||
Button(onClick = viewModel::updateToken) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
} else {
|
||||
Button(onClick = viewModel::addTokenManually) {
|
||||
Text(stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package cn.airnan.a2fair.ui.tokens
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import cn.airnan.a2fair.R
|
||||
import cn.airnan.a2fair.core.OtpUriBuilder
|
||||
import cn.airnan.a2fair.data.entity.Token
|
||||
import cn.airnan.a2fair.data.entity.TokenType
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import java.util.EnumMap
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TokenDetailSheet(
|
||||
token: Token,
|
||||
onDismiss: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var showQrCode by remember { mutableStateOf(false) }
|
||||
var secretVisible by remember { mutableStateOf(false) }
|
||||
|
||||
val secretCopiedText = stringResource(R.string.secret_copied)
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = token.serviceName,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (token.issuer.isNotBlank()) {
|
||||
Text(
|
||||
text = token.issuer,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Info row
|
||||
InfoRow(label = stringResource(R.string.type_label), value = token.tokenType.name)
|
||||
InfoRow(label = stringResource(R.string.algorithm_label), value = token.algorithm.name)
|
||||
InfoRow(label = stringResource(R.string.digits_label), value = token.digits.toString())
|
||||
if (token.tokenType == TokenType.HOTP) {
|
||||
InfoRow(label = stringResource(R.string.counter_label), value = token.counter.toString())
|
||||
} else {
|
||||
InfoRow(label = stringResource(R.string.period_label), value = "${token.period}s")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Secret field
|
||||
Text(
|
||||
text = stringResource(R.string.secret_key),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = token.secret.value,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
visualTransformation = if (secretVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
IconButton(onClick = { secretVisible = !secretVisible }) {
|
||||
Icon(
|
||||
imageVector = if (secretVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (secretVisible) stringResource(R.string.hide_secret) else stringResource(R.string.show_secret)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Secret", token.secret.value))
|
||||
Toast.makeText(context, secretCopiedText, Toast.LENGTH_SHORT).show()
|
||||
}) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.copy_secret))
|
||||
}
|
||||
IconButton(onClick = { showQrCode = true }) {
|
||||
Icon(Icons.Default.QrCode, contentDescription = stringResource(R.string.show_qr_code))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Edit button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onEdit()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Edit, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.edit))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Delete button
|
||||
OutlinedButton(
|
||||
onClick = { showDeleteConfirm = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Delete, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.delete))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
if (showDeleteConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirm = false },
|
||||
title = { Text(stringResource(R.string.confirm_delete)) },
|
||||
text = { Text(stringResource(R.string.confirm_delete_message, token.serviceName)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteConfirm = false
|
||||
onDismiss()
|
||||
onDelete()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
) {
|
||||
Text(stringResource(R.string.delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirm = false }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// QR Code dialog
|
||||
if (showQrCode) {
|
||||
val uriString = remember(token) { OtpUriBuilder.build(token) }
|
||||
val qrBitmap = remember(uriString) { generateQrCode(uriString, 512) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { showQrCode = false },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
shape = MaterialTheme.shapes.large
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.qr_code),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
qrBitmap?.let { bmp ->
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = "QR Code",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TextButton(onClick = { showQrCode = false }) {
|
||||
Text(stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateQrCode(content: String, size: Int): Bitmap? {
|
||||
return try {
|
||||
val hints = EnumMap<EncodeHintType, Any>(EncodeHintType::class.java).apply {
|
||||
this[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
|
||||
this[EncodeHintType.MARGIN] = 1
|
||||
}
|
||||
val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
for (x in 0 until size) {
|
||||
for (y in 0 until size) {
|
||||
bitmap.setPixel(x, y, if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt())
|
||||
}
|
||||
}
|
||||
bitmap
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,6 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
@@ -110,7 +107,11 @@ fun TokensScreen() {
|
||||
TokenCard(
|
||||
token = token,
|
||||
onIncrement = { viewModel.incrementCounter(token) },
|
||||
onDelete = { viewModel.deleteToken(token) }
|
||||
onDelete = { viewModel.deleteToken(token) },
|
||||
onEdit = {
|
||||
viewModel.startEditToken(token)
|
||||
showAddBottomSheet = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -159,14 +160,15 @@ fun GroupHeader(
|
||||
fun TokenCard(
|
||||
token: Token,
|
||||
onIncrement: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
onDelete: () -> Unit,
|
||||
onEdit: () -> Unit
|
||||
) {
|
||||
val otpPlaceholder = stringResource(R.string.otp_placeholder)
|
||||
val errorText = stringResource(R.string.error)
|
||||
var currentCode by remember { mutableStateOf(otpPlaceholder) }
|
||||
var progress by remember { mutableStateOf(1f) }
|
||||
var remainingSeconds by remember { mutableStateOf(0) }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showDetailSheet by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
val secretBytes = remember(token.secret.value) {
|
||||
@@ -232,7 +234,7 @@ fun TokenCard(
|
||||
onClick = {
|
||||
copyToClipboard(context, currentCode, token.serviceName)
|
||||
},
|
||||
onLongClick = { showMenu = true }
|
||||
onLongClick = { showDetailSheet = true }
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
@@ -298,32 +300,18 @@ fun TokenCard(
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.copy_code)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
copyToClipboard(context, currentCode, token.serviceName)
|
||||
if (showDetailSheet) {
|
||||
TokenDetailSheet(
|
||||
token = token,
|
||||
onDismiss = { showDetailSheet = false },
|
||||
onEdit = {
|
||||
showDetailSheet = false
|
||||
onEdit()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.edit)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.delete)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onDelete = {
|
||||
showDetailSheet = false
|
||||
onDelete()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,8 @@ class TokensViewModel(
|
||||
private val _addTokenState = MutableStateFlow(AddTokenUiState())
|
||||
val addTokenState: StateFlow<AddTokenUiState> = _addTokenState.asStateFlow()
|
||||
private var pendingToken: Token? = null
|
||||
private var editingTokenId: Long? = null
|
||||
val isEditing: Boolean get() = editingTokenId != null
|
||||
|
||||
fun updateIssuer(issuer: String) {
|
||||
_addTokenState.update { it.copy(issuer = issuer) }
|
||||
@@ -157,6 +159,53 @@ class TokensViewModel(
|
||||
|
||||
fun resetAddState() {
|
||||
_addTokenState.value = AddTokenUiState()
|
||||
editingTokenId = null
|
||||
pendingToken = null
|
||||
}
|
||||
|
||||
fun startEditToken(token: Token) {
|
||||
editingTokenId = token.id
|
||||
_addTokenState.value = AddTokenUiState(
|
||||
issuer = token.issuer,
|
||||
accountName = token.serviceName,
|
||||
secret = token.secret.value,
|
||||
tokenType = token.tokenType,
|
||||
algorithm = token.algorithm,
|
||||
digits = token.digits,
|
||||
period = token.period,
|
||||
counter = token.counter
|
||||
)
|
||||
}
|
||||
|
||||
fun updateToken() {
|
||||
val state = _addTokenState.value
|
||||
val id = editingTokenId ?: return
|
||||
if (state.secret.isBlank() || state.accountName.isBlank()) {
|
||||
_addTokenState.update { it.copy(isError = true, errorMessage = getApplication<Application>().getString(R.string.secret_and_account_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val token = tokenRepository.getTokenById(id)
|
||||
if (token != null) {
|
||||
val updated = token.copy(
|
||||
issuer = state.issuer,
|
||||
serviceName = state.accountName,
|
||||
secret = SecretString(state.secret),
|
||||
tokenType = state.tokenType,
|
||||
algorithm = state.algorithm,
|
||||
digits = state.digits,
|
||||
period = state.period,
|
||||
counter = state.counter
|
||||
)
|
||||
tokenRepository.updateToken(updated)
|
||||
}
|
||||
resetAddState()
|
||||
_addTokenState.update { it.copy(isSuccess = true) }
|
||||
} catch (e: Exception) {
|
||||
_addTokenState.update { it.copy(isError = true, errorMessage = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addTokenFromUri(uriString: String) {
|
||||
|
||||
@@ -36,6 +36,17 @@
|
||||
<string name="period_label">周期</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="add">添加</string>
|
||||
<string name="save">保存</string>
|
||||
<string name="edit_token">编辑口令</string>
|
||||
<string name="secret_key">密钥</string>
|
||||
<string name="show_secret">显示密钥</string>
|
||||
<string name="hide_secret">隐藏密钥</string>
|
||||
<string name="copy_secret">复制密钥</string>
|
||||
<string name="secret_copied">密钥已复制</string>
|
||||
<string name="show_qr_code">显示二维码</string>
|
||||
<string name="qr_code">二维码</string>
|
||||
<string name="confirm_delete">确认删除</string>
|
||||
<string name="confirm_delete_message">永久删除 \"%s\"?此操作不可撤销。</string>
|
||||
<string name="close">关闭</string>
|
||||
<string name="unknown_error">未知错误</string>
|
||||
<string name="failed_parse_qr">解析二维码失败:%s</string>
|
||||
|
||||
@@ -36,6 +36,17 @@
|
||||
<string name="period_label">週期</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="add">新增</string>
|
||||
<string name="save">儲存</string>
|
||||
<string name="edit_token">編輯權杖</string>
|
||||
<string name="secret_key">金鑰</string>
|
||||
<string name="show_secret">顯示金鑰</string>
|
||||
<string name="hide_secret">隱藏金鑰</string>
|
||||
<string name="copy_secret">複製金鑰</string>
|
||||
<string name="secret_copied">金鑰已複製</string>
|
||||
<string name="show_qr_code">顯示 QR 碼</string>
|
||||
<string name="qr_code">QR 碼</string>
|
||||
<string name="confirm_delete">確認刪除</string>
|
||||
<string name="confirm_delete_message">永久刪除 \"%s\"?此操作無法復原。</string>
|
||||
<string name="close">關閉</string>
|
||||
<string name="unknown_error">未知錯誤</string>
|
||||
<string name="failed_parse_qr">解析 QR 碼失敗:%s</string>
|
||||
|
||||
@@ -36,6 +36,17 @@
|
||||
<string name="period_label">Period</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="add">Add</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="edit_token">Edit Token</string>
|
||||
<string name="secret_key">Secret Key</string>
|
||||
<string name="show_secret">Show secret</string>
|
||||
<string name="hide_secret">Hide secret</string>
|
||||
<string name="copy_secret">Copy secret</string>
|
||||
<string name="secret_copied">Secret copied</string>
|
||||
<string name="show_qr_code">Show QR code</string>
|
||||
<string name="qr_code">QR Code</string>
|
||||
<string name="confirm_delete">Confirm Delete</string>
|
||||
<string name="confirm_delete_message">Delete \"%s\" permanently? This cannot be undone.</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="unknown_error">Unknown error</string>
|
||||
<string name="failed_parse_qr">Failed to parse QR code: %s</string>
|
||||
|
||||
Reference in New Issue
Block a user