Работающий TOTP 2fa

This commit is contained in:
2026-05-17 18:59:54 +03:00
parent 845b3a1d76
commit 3820a60d2c
12 changed files with 713 additions and 40 deletions

View File

@@ -72,6 +72,11 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.mlkit.barcode.scanning)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

@@ -0,0 +1,145 @@
package com.github.nullptroma.wallenc.ui.elements
import android.annotation.SuppressLint
import android.view.ViewGroup
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.github.nullptroma.wallenc.ui.R
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun QrScannerDialog(
onDismiss: () -> Unit,
onScanned: (String) -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
val scanner = remember {
BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build(),
)
}
val consumed = remember { AtomicBoolean(false) }
var previewViewRef by remember { mutableStateOf<PreviewView?>(null) }
DisposableEffect(Unit) {
onDispose {
runCatching { cameraProviderFuture.get().unbindAll() }
runCatching { scanner.close() }
cameraExecutor.shutdown()
}
}
LaunchedEffect(previewViewRef) {
val previewView = previewViewRef ?: return@LaunchedEffect
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { imageAnalysis ->
imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy ->
val mediaImage = imageProxy.image
if (mediaImage == null) {
imageProxy.close()
return@setAnalyzer
}
val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(input)
.addOnSuccessListener { barcodes ->
if (consumed.get()) return@addOnSuccessListener
val raw = barcodes.firstOrNull()?.rawValue?.trim().orEmpty()
if (raw.isNotBlank() && consumed.compareAndSet(false, true)) {
onScanned(raw)
}
}
.addOnCompleteListener {
imageProxy.close()
}
}
}
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis,
)
}
BasicAlertDialog(onDismissRequest = onDismiss) {
Card {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.two_fa_scan_qr_title),
style = MaterialTheme.typography.titleLarge,
)
AndroidView(
modifier = Modifier
.fillMaxWidth()
.height(380.dp),
factory = { ctx ->
PreviewView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
previewViewRef = this
}
},
)
Spacer(modifier = Modifier.height(4.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onDismiss,
) {
Text(text = stringResource(R.string.cancel))
}
}
}
}
}

View File

@@ -0,0 +1,61 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
import android.net.Uri
data class ParsedOtpAuthToken(
val issuer: String,
val account: String,
val secret: String,
val digits: Int,
val periodSeconds: Int,
val algorithm: String,
)
fun parseOtpAuthTotpUri(raw: String): ParsedOtpAuthToken? {
val uri = runCatching { Uri.parse(raw.trim()) }.getOrNull() ?: return null
if (!uri.scheme.equals("otpauth", ignoreCase = true)) return null
if (!uri.host.equals("totp", ignoreCase = true)) return null
val secret = uri.getQueryParameter("secret")?.trim().orEmpty()
if (secret.isBlank()) return null
val label = uri.path.orEmpty().trim('/').trim()
val queryIssuer = uri.getQueryParameter("issuer")?.trim().orEmpty()
val (issuerFromLabel, accountFromLabel) = splitLabel(label)
val issuer = queryIssuer.ifBlank { issuerFromLabel }.ifBlank { "TOTP" }
val account = accountFromLabel.ifBlank { label.ifBlank { "account" } }
val digits = uri.getQueryParameter("digits")?.toIntOrNull()?.coerceIn(6, 8) ?: 6
val periodSeconds = uri.getQueryParameter("period")?.toIntOrNull()?.coerceAtLeast(1) ?: 30
val algorithm = normalizeAlgorithm(uri.getQueryParameter("algorithm") ?: "SHA1")
return ParsedOtpAuthToken(
issuer = issuer,
account = account,
secret = secret,
digits = digits,
periodSeconds = periodSeconds,
algorithm = algorithm,
)
}
private fun splitLabel(label: String): Pair<String, String> {
val idx = label.indexOf(':')
return if (idx >= 0) {
label.substring(0, idx).trim() to label.substring(idx + 1).trim()
} else {
"" to label.trim()
}
}
private fun normalizeAlgorithm(input: String): String {
val normalized = input
.trim()
.uppercase()
.replace("HMAC", "")
.replace("-", "")
.replace("_", "")
return when (normalized) {
"SHA1", "SHA256", "SHA512" -> normalized
else -> "SHA1"
}
}

View File

@@ -1,25 +1,37 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
import android.Manifest
import android.content.pm.PackageManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.QrCodeScanner
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -27,21 +39,46 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.DropdownMenu
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.graphics.Color
import androidx.core.content.ContextCompat
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.elements.QrScannerDialog
import com.github.nullptroma.wallenc.usecases.TwoFaCodeState
import com.github.nullptroma.wallenc.usecases.buildTwoFaCodeState
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
import androidx.compose.ui.unit.IntOffset
@Composable
fun TwoFaTokensScreen(
@@ -49,6 +86,13 @@ fun TwoFaTokensScreen(
viewModel: TwoFaTokensViewModel = hiltViewModel(),
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current
val nowMillis by produceState(initialValue = System.currentTimeMillis()) {
while (true) {
value = System.currentTimeMillis()
delay(1000)
}
}
var editingToken by remember { mutableStateOf<TwoFaTokenRecord?>(null) }
var creating by remember { mutableStateOf(false) }
@@ -95,6 +139,7 @@ fun TwoFaTokensScreen(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
val codeState = rememberTwoFaCode(item, nowMillis)
Row(
modifier = Modifier
.fillMaxWidth()
@@ -123,12 +168,6 @@ fun TwoFaTokensScreen(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.size(4.dp))
Text(
text = maskedSecret(item.secret),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row {
@@ -146,6 +185,84 @@ fun TwoFaTokensScreen(
}
}
}
val codeProgress = if (codeState == null) 0f else {
val period = item.periodSeconds.coerceAtLeast(1)
val elapsed = (period - codeState.secondsUntilRefresh)
.coerceAtLeast(0)
.coerceAtMost(period)
elapsed.toFloat() / period.toFloat()
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 14.dp, end = 14.dp, bottom = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.Top,
) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = stringResource(R.string.two_fa_code_refresh_label),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = if (codeState != null) {
stringResource(
R.string.two_fa_code_refresh_seconds,
codeState.secondsUntilRefresh,
)
} else {
stringResource(R.string.two_fa_code_invalid_secret)
},
style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp),
color = if (codeState != null) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
Color.Red
},
)
}
Card(
onClick = {
codeState?.let {
clipboard.setText(AnnotatedString(it.code))
}
},
enabled = codeState != null,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = codeState?.code ?: stringResource(R.string.two_fa_code_unavailable),
style = MaterialTheme.typography.titleLarge,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.End,
)
}
}
}
}
LinearProgressIndicator(
progress = { codeProgress },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@@ -158,7 +275,7 @@ fun TwoFaTokensScreen(
startValue = null,
isBusy = uiState.isMutating,
onDismiss = { creating = false },
onSave = { issuer, account, secret, notes ->
onSave = { issuer, account, secret, notes, digits, periodSeconds, algorithm ->
creating = false
viewModel.saveToken(
existingId = null,
@@ -166,6 +283,9 @@ fun TwoFaTokensScreen(
account = account,
secret = secret,
notes = notes,
digits = digits,
periodSeconds = periodSeconds,
algorithm = algorithm,
)
},
)
@@ -176,7 +296,7 @@ fun TwoFaTokensScreen(
startValue = token,
isBusy = uiState.isMutating,
onDismiss = { editingToken = null },
onSave = { issuer, account, secret, notes ->
onSave = { issuer, account, secret, notes, digits, periodSeconds, algorithm ->
editingToken = null
viewModel.saveToken(
existingId = token.id,
@@ -184,23 +304,47 @@ fun TwoFaTokensScreen(
account = account,
secret = secret,
notes = notes,
digits = digits,
periodSeconds = periodSeconds,
algorithm = algorithm,
)
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TwoFaTokenEditDialog(
startValue: TwoFaTokenRecord?,
isBusy: Boolean,
onDismiss: () -> Unit,
onSave: (String, String, String, String?) -> Unit,
onSave: (String, String, String, String?, Int, Int, String) -> Unit,
) {
val context = LocalContext.current
var issuer by remember(startValue) { mutableStateOf(startValue?.issuer.orEmpty()) }
var account by remember(startValue) { mutableStateOf(startValue?.account.orEmpty()) }
var secret by remember(startValue) { mutableStateOf(startValue?.secret.orEmpty()) }
var notes by remember(startValue) { mutableStateOf(startValue?.notes.orEmpty()) }
var digitsValue by remember(startValue) { mutableStateOf((startValue?.digits ?: 6).coerceIn(6, 8)) }
var periodSecondsValue by remember(startValue) { mutableStateOf((startValue?.periodSeconds ?: 30).coerceIn(10, 120)) }
var algorithmValue by remember(startValue) { mutableStateOf((startValue?.algorithm ?: "SHA1").uppercase()) }
var algorithmExpanded by remember { mutableStateOf(false) }
var showScanner by remember { mutableStateOf(false) }
var scanError by remember { mutableStateOf<String?>(null) }
var permissionDenied by remember { mutableStateOf(false) }
val dialogScrollState = rememberScrollState()
var scrollContainerHeightPx by remember { mutableStateOf(0) }
val cameraPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
showScanner = true
permissionDenied = false
} else {
permissionDenied = true
}
}
AlertDialog(
onDismissRequest = { if (!isBusy) onDismiss() },
@@ -214,40 +358,186 @@ private fun TwoFaTokenEditDialog(
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (isBusy) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 520.dp)
.onSizeChanged { scrollContainerHeightPx = it.height },
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(end = 10.dp)
.verticalScroll(dialogScrollState),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (isBusy) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
OutlinedTextField(
value = issuer,
onValueChange = { issuer = it },
enabled = !isBusy,
label = { Text(stringResource(R.string.two_fa_field_issuer)) },
)
OutlinedTextField(
value = account,
onValueChange = { account = it },
enabled = !isBusy,
label = { Text(stringResource(R.string.two_fa_field_account)) },
)
OutlinedTextField(
value = secret,
onValueChange = { secret = it },
enabled = !isBusy,
label = { Text(stringResource(R.string.two_fa_field_secret)) },
)
Text(
text = stringResource(R.string.two_fa_field_algorithm),
style = MaterialTheme.typography.labelMedium,
)
ExposedDropdownMenuBox(
expanded = algorithmExpanded,
onExpandedChange = { if (!isBusy) algorithmExpanded = !algorithmExpanded },
) {
OutlinedTextField(
value = algorithmValue,
onValueChange = {},
readOnly = true,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = algorithmExpanded)
},
)
DropdownMenu(
expanded = algorithmExpanded,
onDismissRequest = { algorithmExpanded = false },
) {
listOf("SHA1", "SHA256", "SHA512").forEach { algo ->
DropdownMenuItem(
text = { Text(algo) },
onClick = {
algorithmValue = algo
algorithmExpanded = false
},
)
}
}
}
Text(
text = stringResource(R.string.two_fa_field_period_seconds_value, periodSecondsValue),
style = MaterialTheme.typography.labelMedium,
)
Slider(
value = periodSecondsValue.toFloat(),
onValueChange = { periodSecondsValue = it.roundToInt().coerceIn(10, 120) },
valueRange = 10f..120f,
steps = 109,
enabled = !isBusy,
)
Text(
text = stringResource(R.string.two_fa_field_digits_value, digitsValue),
style = MaterialTheme.typography.labelMedium,
)
Slider(
value = digitsValue.toFloat(),
onValueChange = { digitsValue = it.roundToInt().coerceIn(6, 8) },
valueRange = 6f..8f,
steps = 1,
enabled = !isBusy,
)
TextButton(
enabled = !isBusy,
onClick = {
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
showScanner = true
permissionDenied = false
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
},
) {
Icon(
imageVector = Icons.Default.QrCodeScanner,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
)
Text(stringResource(R.string.two_fa_scan_qr_action))
}
if (permissionDenied) {
Text(
text = stringResource(R.string.two_fa_camera_permission_required),
color = Color.Red,
style = MaterialTheme.typography.bodySmall,
)
}
scanError?.let { error ->
Text(
text = error,
color = Color.Red,
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
enabled = !isBusy,
label = { Text(stringResource(R.string.two_fa_field_notes_optional)) },
)
}
val trackColor = MaterialTheme.colorScheme.outlineVariant
val thumbColor = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxSize()
.padding(end = 2.dp, top = 4.dp, bottom = 4.dp),
contentAlignment = Alignment.TopEnd,
) {
Box(
modifier = Modifier
.fillMaxHeight()
.size(width = 3.dp, height = 0.dp)
.background(trackColor),
)
if (dialogScrollState.maxValue > 0 && scrollContainerHeightPx > 0) {
val viewport = scrollContainerHeightPx.toFloat()
val content = viewport + dialogScrollState.maxValue.toFloat()
val thumbHeightPx = (viewport * (viewport / content)).coerceAtLeast(24f)
val maxTravel = (viewport - thumbHeightPx).coerceAtLeast(0f)
val offsetY = if (dialogScrollState.maxValue == 0) 0f
else maxTravel * (dialogScrollState.value.toFloat() / dialogScrollState.maxValue.toFloat())
Box(
modifier = Modifier
.offset { IntOffset(0, offsetY.roundToInt()) }
.size(width = 3.dp, height = (thumbHeightPx / context.resources.displayMetrics.density).dp)
.background(thumbColor),
)
}
}
OutlinedTextField(
value = issuer,
onValueChange = { issuer = it },
enabled = !isBusy,
label = { Text(stringResource(R.string.two_fa_field_issuer)) },
)
OutlinedTextField(
value = account,
onValueChange = { account = it },
enabled = !isBusy,
label = { Text(stringResource(R.string.two_fa_field_account)) },
)
OutlinedTextField(
value = secret,
onValueChange = { secret = it },
enabled = !isBusy,
label = { Text(stringResource(R.string.two_fa_field_secret)) },
)
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
enabled = !isBusy,
label = { Text(stringResource(R.string.two_fa_field_notes_optional)) },
)
}
},
confirmButton = {
TextButton(
enabled = !isBusy && issuer.isNotBlank() && account.isNotBlank() && secret.isNotBlank(),
onClick = { onSave(issuer, account, secret, notes.ifBlank { null }) },
onClick = {
onSave(
issuer,
account,
secret,
notes.ifBlank { null },
digitsValue,
periodSecondsValue,
algorithmValue,
)
},
) {
Text(stringResource(R.string.save))
}
@@ -258,9 +548,32 @@ private fun TwoFaTokenEditDialog(
}
},
)
if (showScanner) {
QrScannerDialog(
onDismiss = { showScanner = false },
onScanned = { raw ->
val parsed = parseOtpAuthTotpUri(raw)
if (parsed == null) {
scanError = context.getString(R.string.two_fa_scan_qr_invalid)
return@QrScannerDialog
}
issuer = parsed.issuer
account = parsed.account
secret = parsed.secret
digitsValue = parsed.digits.coerceIn(6, 8)
periodSecondsValue = parsed.periodSeconds.coerceIn(10, 120)
algorithmValue = parsed.algorithm.uppercase()
scanError = null
showScanner = false
},
)
}
}
private fun maskedSecret(secret: String): String {
if (secret.isBlank()) return ""
return "••••••••••••"
@Composable
private fun rememberTwoFaCode(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? {
return remember(token, nowMillis) {
buildTwoFaCodeState(token, nowMillis)
}
}

View File

@@ -83,6 +83,9 @@ class TwoFaTokensViewModel @Inject constructor(
account: String,
secret: String,
notes: String?,
digits: Int = 6,
periodSeconds: Int = 30,
algorithm: String = "SHA1",
) {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
@@ -106,6 +109,9 @@ class TwoFaTokensViewModel @Inject constructor(
account = account,
secret = secret,
notes = notes,
digits = digits,
periodSeconds = periodSeconds,
algorithm = algorithm,
)
} else {
manageTwoFaTokensUseCase.update(
@@ -116,6 +122,9 @@ class TwoFaTokensViewModel @Inject constructor(
account = account,
secret = secret,
notes = notes,
digits = digits,
periodSeconds = periodSeconds,
algorithm = algorithm,
),
)
}

View File

@@ -215,6 +215,21 @@
<string name="two_fa_field_account">Аккаунт</string>
<string name="two_fa_field_secret">Секрет</string>
<string name="two_fa_field_notes_optional">Заметка (опционально)</string>
<string name="two_fa_field_digits">Количество цифр кода (обычно 6 или 8)</string>
<string name="two_fa_field_period_seconds">Период обновления в секундах (обычно 30)</string>
<string name="two_fa_field_algorithm">Алгоритм (SHA1, SHA256, SHA512)</string>
<string name="two_fa_field_digits_value">Количество цифр: %1$d</string>
<string name="two_fa_field_period_seconds_value">Период обновления: %1$d с</string>
<string name="two_fa_code_unavailable">------</string>
<string name="two_fa_code_refresh_in">Обновление через %1$d с</string>
<string name="two_fa_code_refresh_label">Обновление через</string>
<string name="two_fa_code_refresh_seconds">%1$d с</string>
<string name="two_fa_code_invalid_secret">Неверный секрет или формат</string>
<string name="two_fa_copy_code_hint">Нажмите, чтобы скопировать код</string>
<string name="two_fa_scan_qr_action">Сканировать QR</string>
<string name="two_fa_scan_qr_title">Сканирование QR-кода TOTP</string>
<string name="two_fa_scan_qr_invalid">QR-код не содержит валидный otpauth://totp URI</string>
<string name="two_fa_camera_permission_required">Нужно разрешение на камеру для сканирования QR</string>
<string name="text_secret_create">Создать секрет</string>
<string name="text_secret_edit">Редактировать секрет</string>