From d60b102f83f194a75574f9880db78c2882436943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=81=E5=86=9C=E9=98=BF=E6=A5=A0?= Date: Tue, 16 Jun 2026 22:26:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=BC=96=E8=BE=91=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E6=97=A0=E6=B3=95=E6=89=93=E5=BC=80=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cn/airnan/a2fair/core/OtpUriBuilder.kt | 46 +++ .../a2fair/ui/tokens/AddTokenBottomSheet.kt | 32 ++- .../a2fair/ui/tokens/TokenDetailSheet.kt | 269 ++++++++++++++++++ .../airnan/a2fair/ui/tokens/TokensScreen.kt | 50 ++-- .../a2fair/ui/tokens/TokensViewModel.kt | 49 ++++ app/src/main/res/values-zh-rCN/strings.xml | 11 + app/src/main/res/values-zh-rTW/strings.xml | 11 + app/src/main/res/values/strings.xml | 11 + 8 files changed, 441 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/cn/airnan/a2fair/core/OtpUriBuilder.kt create mode 100644 app/src/main/java/cn/airnan/a2fair/ui/tokens/TokenDetailSheet.kt diff --git a/app/src/main/java/cn/airnan/a2fair/core/OtpUriBuilder.kt b/app/src/main/java/cn/airnan/a2fair/core/OtpUriBuilder.kt new file mode 100644 index 0000000..4146bbb --- /dev/null +++ b/app/src/main/java/cn/airnan/a2fair/core/OtpUriBuilder.kt @@ -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() + } +} diff --git a/app/src/main/java/cn/airnan/a2fair/ui/tokens/AddTokenBottomSheet.kt b/app/src/main/java/cn/airnan/a2fair/ui/tokens/AddTokenBottomSheet.kt index d379b44..afa17b0 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/tokens/AddTokenBottomSheet.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/tokens/AddTokenBottomSheet.kt @@ -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,8 +351,14 @@ fun ManualEntryForm( } Spacer(modifier = Modifier.width(8.dp)) if (!state.isDuplicate) { - Button(onClick = viewModel::addTokenManually) { - Text(stringResource(R.string.add)) + if (isEditing) { + Button(onClick = viewModel::updateToken) { + Text(stringResource(R.string.save)) + } + } else { + Button(onClick = viewModel::addTokenManually) { + Text(stringResource(R.string.add)) + } } } } diff --git a/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokenDetailSheet.kt b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokenDetailSheet.kt new file mode 100644 index 0000000..4f3e6f8 --- /dev/null +++ b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokenDetailSheet.kt @@ -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::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 + } +} diff --git a/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensScreen.kt b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensScreen.kt index 42e0235..51bf601 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensScreen.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensScreen.kt @@ -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) } + } ) } } diff --git a/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensViewModel.kt b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensViewModel.kt index 9c16655..b0d8f3b 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensViewModel.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensViewModel.kt @@ -121,6 +121,8 @@ class TokensViewModel( private val _addTokenState = MutableStateFlow(AddTokenUiState()) val addTokenState: StateFlow = _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().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) { diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5c82b4e..df2000d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -36,6 +36,17 @@ 周期 取消 添加 + 保存 + 编辑口令 + 密钥 + 显示密钥 + 隐藏密钥 + 复制密钥 + 密钥已复制 + 显示二维码 + 二维码 + 确认删除 + 永久删除 \"%s\"?此操作不可撤销。 关闭 未知错误 解析二维码失败:%s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f8feb25..2b25b17 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -36,6 +36,17 @@ 週期 取消 新增 + 儲存 + 編輯權杖 + 金鑰 + 顯示金鑰 + 隱藏金鑰 + 複製金鑰 + 金鑰已複製 + 顯示 QR 碼 + QR 碼 + 確認刪除 + 永久刪除 \"%s\"?此操作無法復原。 關閉 未知錯誤 解析 QR 碼失敗:%s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96ca050..79e17fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,6 +36,17 @@ Period Cancel Add + Save + Edit Token + Secret Key + Show secret + Hide secret + Copy secret + Secret copied + Show QR code + QR Code + Confirm Delete + Delete \"%s\" permanently? This cannot be undone. Close Unknown error Failed to parse QR code: %s