修正编辑弹窗无法打开的问题

This commit is contained in:
2026-06-16 22:26:37 +08:00
parent edee2426db
commit d60b102f83
8 changed files with 441 additions and 38 deletions
@@ -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>
+11
View File
@@ -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>