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

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.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cn.airnan.a2fair.R import cn.airnan.a2fair.R
import cn.airnan.a2fair.data.entity.HashAlgorithm import cn.airnan.a2fair.data.entity.HashAlgorithm
import cn.airnan.a2fair.data.entity.TokenType import cn.airnan.a2fair.data.entity.TokenType
@@ -52,9 +53,10 @@ fun AddTokenBottomSheet(
viewModel: TokensViewModel, viewModel: TokensViewModel,
onDismissRequest: () -> Unit onDismissRequest: () -> Unit
) { ) {
val state by viewModel.addTokenState.collectAsState() val state by viewModel.addTokenState.collectAsStateWithLifecycle()
val isEditing = viewModel.isEditing
val context = LocalContext.current val context = LocalContext.current
var showManualEntry by remember { mutableStateOf(false) } var showManualEntry by remember { mutableStateOf(isEditing) }
var showCameraScanner by remember { mutableStateOf(false) } var showCameraScanner by remember { mutableStateOf(false) }
val photoPickerLauncher = rememberLauncherForActivityResult( val photoPickerLauncher = rememberLauncherForActivityResult(
@@ -113,7 +115,11 @@ fun AddTokenBottomSheet(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( 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, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
@@ -155,7 +161,12 @@ fun AddTokenBottomSheet(
ManualEntryForm( ManualEntryForm(
state = state, state = state,
viewModel = viewModel, viewModel = viewModel,
onCancel = { showManualEntry = false } onCancel = if (isEditing) {
{ viewModel.resetAddState(); onDismissRequest() }
} else {
{ showManualEntry = false }
},
isEditing = isEditing
) )
} }
} }
@@ -177,7 +188,8 @@ fun AddTokenBottomSheet(
fun ManualEntryForm( fun ManualEntryForm(
state: AddTokenUiState, state: AddTokenUiState,
viewModel: TokensViewModel, viewModel: TokensViewModel,
onCancel: () -> Unit onCancel: () -> Unit,
isEditing: Boolean = false
) { ) {
OutlinedTextField( OutlinedTextField(
value = state.accountName, value = state.accountName,
@@ -339,11 +351,17 @@ fun ManualEntryForm(
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
if (!state.isDuplicate) { if (!state.isDuplicate) {
if (isEditing) {
Button(onClick = viewModel::updateToken) {
Text(stringResource(R.string.save))
}
} else {
Button(onClick = viewModel::addTokenManually) { Button(onClick = viewModel::addTokenManually) {
Text(stringResource(R.string.add)) Text(stringResource(R.string.add))
} }
} }
} }
}
} }
@Composable @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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
@@ -110,7 +107,11 @@ fun TokensScreen() {
TokenCard( TokenCard(
token = token, token = token,
onIncrement = { viewModel.incrementCounter(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( fun TokenCard(
token: Token, token: Token,
onIncrement: () -> Unit, onIncrement: () -> Unit,
onDelete: () -> Unit onDelete: () -> Unit,
onEdit: () -> Unit
) { ) {
val otpPlaceholder = stringResource(R.string.otp_placeholder) val otpPlaceholder = stringResource(R.string.otp_placeholder)
val errorText = stringResource(R.string.error) val errorText = stringResource(R.string.error)
var currentCode by remember { mutableStateOf(otpPlaceholder) } var currentCode by remember { mutableStateOf(otpPlaceholder) }
var progress by remember { mutableStateOf(1f) } var progress by remember { mutableStateOf(1f) }
var remainingSeconds by remember { mutableStateOf(0) } var remainingSeconds by remember { mutableStateOf(0) }
var showMenu by remember { mutableStateOf(false) } var showDetailSheet by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
val secretBytes = remember(token.secret.value) { val secretBytes = remember(token.secret.value) {
@@ -232,7 +234,7 @@ fun TokenCard(
onClick = { onClick = {
copyToClipboard(context, currentCode, token.serviceName) copyToClipboard(context, currentCode, token.serviceName)
}, },
onLongClick = { showMenu = true } onLongClick = { showDetailSheet = true }
), ),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
@@ -298,32 +300,18 @@ fun TokenCard(
} }
} }
DropdownMenu( if (showDetailSheet) {
expanded = showMenu, TokenDetailSheet(
onDismissRequest = { showMenu = false } token = token,
) { onDismiss = { showDetailSheet = false },
DropdownMenuItem( onEdit = {
text = { Text(stringResource(R.string.copy_code)) }, showDetailSheet = false
onClick = { onEdit()
showMenu = false
copyToClipboard(context, currentCode, token.serviceName)
}, },
leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) } onDelete = {
) showDetailSheet = false
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() onDelete()
}, }
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }
) )
} }
} }
@@ -121,6 +121,8 @@ class TokensViewModel(
private val _addTokenState = MutableStateFlow(AddTokenUiState()) private val _addTokenState = MutableStateFlow(AddTokenUiState())
val addTokenState: StateFlow<AddTokenUiState> = _addTokenState.asStateFlow() val addTokenState: StateFlow<AddTokenUiState> = _addTokenState.asStateFlow()
private var pendingToken: Token? = null private var pendingToken: Token? = null
private var editingTokenId: Long? = null
val isEditing: Boolean get() = editingTokenId != null
fun updateIssuer(issuer: String) { fun updateIssuer(issuer: String) {
_addTokenState.update { it.copy(issuer = issuer) } _addTokenState.update { it.copy(issuer = issuer) }
@@ -157,6 +159,53 @@ class TokensViewModel(
fun resetAddState() { fun resetAddState() {
_addTokenState.value = AddTokenUiState() _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) { fun addTokenFromUri(uriString: String) {
@@ -36,6 +36,17 @@
<string name="period_label">周期</string> <string name="period_label">周期</string>
<string name="cancel">取消</string> <string name="cancel">取消</string>
<string name="add">添加</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="close">关闭</string>
<string name="unknown_error">未知错误</string> <string name="unknown_error">未知错误</string>
<string name="failed_parse_qr">解析二维码失败:%s</string> <string name="failed_parse_qr">解析二维码失败:%s</string>
@@ -36,6 +36,17 @@
<string name="period_label">週期</string> <string name="period_label">週期</string>
<string name="cancel">取消</string> <string name="cancel">取消</string>
<string name="add">新增</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="close">關閉</string>
<string name="unknown_error">未知錯誤</string> <string name="unknown_error">未知錯誤</string>
<string name="failed_parse_qr">解析 QR 碼失敗:%s</string> <string name="failed_parse_qr">解析 QR 碼失敗:%s</string>
+11
View File
@@ -36,6 +36,17 @@
<string name="period_label">Period</string> <string name="period_label">Period</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="add">Add</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="close">Close</string>
<string name="unknown_error">Unknown error</string> <string name="unknown_error">Unknown error</string>
<string name="failed_parse_qr">Failed to parse QR code: %s</string> <string name="failed_parse_qr">Failed to parse QR code: %s</string>