From edee2426dba92f3722340804967fa65c464e5f01 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 20:47:51 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=AF=86=E9=92=A5=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=97=B6=E8=BF=9B=E8=A1=8C=E6=8B=A6=E6=88=AA=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../a2fair/ui/tokens/AddTokenBottomSheet.kt | 43 +++++++++++++- .../a2fair/ui/tokens/TokensViewModel.kt | 56 +++++++++++++++++-- app/src/main/res/values-zh-rCN/strings.xml | 4 ++ app/src/main/res/values-zh-rTW/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ 5 files changed, 104 insertions(+), 7 deletions(-) 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 d9ee300..d379b44 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 @@ -293,6 +293,43 @@ fun ManualEntryForm( } } + if (state.isDuplicate) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.duplicate_token_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.duplicate_token_message, state.duplicateServiceName), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = { viewModel.dismissDuplicate() }) { + Text(stringResource(R.string.skip)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { viewModel.forceAddToken() }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Text(stringResource(R.string.force_add)) + } + } + } + } + } + Row( modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp), horizontalArrangement = Arrangement.End @@ -301,8 +338,10 @@ fun ManualEntryForm( Text(stringResource(R.string.cancel)) } Spacer(modifier = Modifier.width(8.dp)) - Button(onClick = viewModel::addTokenManually) { - Text(stringResource(R.string.add)) + if (!state.isDuplicate) { + Button(onClick = viewModel::addTokenManually) { + Text(stringResource(R.string.add)) + } } } } 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 53dfbf5..9c16655 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 @@ -16,6 +16,7 @@ import cn.airnan.a2fair.data.repository.TokenRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -35,7 +36,9 @@ data class AddTokenUiState( val counter: Long = 0, val isError: Boolean = false, val errorMessage: String? = null, - val isSuccess: Boolean = false + val isSuccess: Boolean = false, + val isDuplicate: Boolean = false, + val duplicateServiceName: String = "" ) data class TokensUiState( @@ -117,6 +120,7 @@ class TokensViewModel( private val _addTokenState = MutableStateFlow(AddTokenUiState()) val addTokenState: StateFlow = _addTokenState.asStateFlow() + private var pendingToken: Token? = null fun updateIssuer(issuer: String) { _addTokenState.update { it.copy(issuer = issuer) } @@ -127,8 +131,8 @@ class TokensViewModel( } fun updateSecret(secret: String) { - // Strip spaces - _addTokenState.update { it.copy(secret = secret.replace(" ", "")) } + // Strip spaces and uppercase (Base32 is case-insensitive) + _addTokenState.update { it.copy(secret = secret.replace(" ", "").uppercase()) } } fun updateTokenType(type: TokenType) { @@ -158,7 +162,8 @@ class TokensViewModel( fun addTokenFromUri(uriString: String) { try { val token = OtpUriParser.parse(uriString) - saveToken(token) + // Normalize secret to uppercase (Base32 is case-insensitive) + saveToken(token.copy(secret = SecretString(token.secret.value.uppercase()))) } catch (e: Exception) { _addTokenState.update { it.copy(isError = true, errorMessage = getApplication().getString(R.string.failed_parse_qr, e.message ?: "")) } } @@ -188,17 +193,58 @@ class TokensViewModel( } } + private fun String.normalizeSecret(): String { + return this.uppercase().replace(Regex("[^A-Z2-7]"), "") + } + private fun saveToken(token: Token) { viewModelScope.launch { try { + // Check for duplicate + val existing = tokenRepository.getAllTokens().first() + val duplicate = existing.find { + it.secret.value.normalizeSecret() == token.secret.value.normalizeSecret() + } + if (duplicate != null) { + pendingToken = token + _addTokenState.update { + it.copy( + isDuplicate = true, + duplicateServiceName = duplicate.serviceName, + isError = false, + errorMessage = null + ) + } + return@launch + } + tokenRepository.insertToken(token) - _addTokenState.update { it.copy(isSuccess = true, isError = false, errorMessage = null) } + pendingToken = null + _addTokenState.update { it.copy(isSuccess = true, isError = false, errorMessage = null, isDuplicate = false) } } catch (e: Exception) { _addTokenState.update { it.copy(isError = true, errorMessage = getApplication().getString(R.string.failed_save_token, e.message ?: "")) } } } } + fun dismissDuplicate() { + pendingToken = null + _addTokenState.update { it.copy(isDuplicate = false, duplicateServiceName = "") } + } + + fun forceAddToken() { + val token = pendingToken ?: return + viewModelScope.launch { + try { + tokenRepository.insertToken(token) + pendingToken = null + _addTokenState.update { it.copy(isSuccess = true, isDuplicate = false, isError = false, errorMessage = null) } + } catch (e: Exception) { + _addTokenState.update { it.copy(isError = true, errorMessage = getApplication().getString(R.string.failed_save_token, e.message ?: ""), isDuplicate = false) } + } + } + } + fun clearError() { _addTokenState.update { it.copy(isError = false, errorMessage = null) } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0b417bb..5c82b4e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -41,6 +41,10 @@ 解析二维码失败:%s 密钥和账户名称为必填项 保存令牌失败:%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 7272dd9..f8feb25 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -41,6 +41,10 @@ 解析 QR 碼失敗:%s 金鑰和帳戶名稱為必填項 儲存權杖失敗:%s + 權杖重複 + 權杖 \"%s\" 已存在,是否仍要新增? + 跳過 + 強制新增 安全性 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 37e98ae..96ca050 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,10 @@ Failed to parse QR code: %s Secret and Account Name are required Failed to save token: %s + Duplicate Token + Token \"%s\" already exists. Do you want to add it anyway? + Skip + Force Add Security