diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8a81c59..407980f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,7 @@
+
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(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))
+ }
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParser.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParser.kt
new file mode 100644
index 0000000..9c69eb2
--- /dev/null
+++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParser.kt
@@ -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 {
+ 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"
+ }
+}
diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt
index 2ec226f..13aadf7 100644
--- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt
+++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt
@@ -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(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(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)
+ }
}
diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt
index c2a960e..f2c0ee4 100644
--- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt
+++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt
@@ -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,
),
)
}
diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml
index 46d79e0..4b9d5bb 100644
--- a/ui/src/main/res/values/strings.xml
+++ b/ui/src/main/res/values/strings.xml
@@ -215,6 +215,21 @@
Аккаунт
Секрет
Заметка (опционально)
+ Количество цифр кода (обычно 6 или 8)
+ Период обновления в секундах (обычно 30)
+ Алгоритм (SHA1, SHA256, SHA512)
+ Количество цифр: %1$d
+ Период обновления: %1$d с
+ ------
+ Обновление через %1$d с
+ Обновление через
+ %1$d с
+ Неверный секрет или формат
+ Нажмите, чтобы скопировать код
+ Сканировать QR
+ Сканирование QR-кода TOTP
+ QR-код не содержит валидный otpauth://totp URI
+ Нужно разрешение на камеру для сканирования QR
Создать секрет
Редактировать секрет
diff --git a/usecases/build.gradle.kts b/usecases/build.gradle.kts
index 625ac0f..7cb6383 100644
--- a/usecases/build.gradle.kts
+++ b/usecases/build.gradle.kts
@@ -17,5 +17,6 @@ dependencies {
implementation(project(":domain"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
+ implementation(libs.java.otp)
testImplementation(libs.junit)
}
diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt
index 4cae690..adb3bfc 100644
--- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt
+++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt
@@ -49,6 +49,9 @@ class ManageTwoFaTokensUseCase {
account: String,
secret: String,
notes: String? = null,
+ digits: Int = 6,
+ periodSeconds: Int = 30,
+ algorithm: String = "SHA1",
): TwoFaTokenRecord = mutex.withLock {
val storage = storageInfo as? IStorage ?: error("Storage is not writable")
val next = TwoFaTokenRecord(
@@ -57,6 +60,9 @@ class ManageTwoFaTokensUseCase {
account = account.trim(),
secret = secret.trim(),
notes = notes?.trim().takeUnless { it.isNullOrBlank() },
+ digits = digits.coerceIn(6, 8),
+ periodSeconds = periodSeconds.coerceAtLeast(1),
+ algorithm = normalizeAlgorithm(algorithm),
)
val updated = readAll(storage).toMutableList().apply { add(next) }
writeAll(storage, updated)
@@ -108,12 +114,18 @@ class ManageTwoFaTokensUseCase {
val account = obj["account"]?.jsonPrimitive?.contentOrNull ?: return null
val secret = obj["secret"]?.jsonPrimitive?.contentOrNull ?: return null
val notes = obj["notes"]?.jsonPrimitive?.contentOrNull
+ val digits = obj["digits"]?.jsonPrimitive?.contentOrNull?.toIntOrNull()?.coerceIn(6, 8) ?: 6
+ val periodSeconds = obj["periodSeconds"]?.jsonPrimitive?.contentOrNull?.toIntOrNull()?.coerceAtLeast(1) ?: 30
+ val algorithm = normalizeAlgorithm(obj["algorithm"]?.jsonPrimitive?.contentOrNull ?: "SHA1")
return TwoFaTokenRecord(
id = id,
issuer = issuer,
account = account,
secret = secret,
notes = notes,
+ digits = digits,
+ periodSeconds = periodSeconds,
+ algorithm = algorithm,
)
}
@@ -123,6 +135,22 @@ class ManageTwoFaTokensUseCase {
put("account", JsonPrimitive(record.account))
put("secret", JsonPrimitive(record.secret))
record.notes?.let { put("notes", JsonPrimitive(it)) }
+ put("digits", JsonPrimitive(record.digits))
+ put("periodSeconds", JsonPrimitive(record.periodSeconds))
+ put("algorithm", JsonPrimitive(normalizeAlgorithm(record.algorithm)))
+ }
+
+ private fun normalizeAlgorithm(algorithm: String): String {
+ val normalized = algorithm
+ .trim()
+ .uppercase()
+ .replace("HMAC", "")
+ .replace("-", "")
+ .replace("_", "")
+ return when (normalized) {
+ "SHA1", "SHA256", "SHA512" -> normalized
+ else -> "SHA1"
+ }
}
private fun domainFilePathEquals(left: String, right: String): Boolean =
diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/TwoFaTotp.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/TwoFaTotp.kt
new file mode 100644
index 0000000..0b7e11a
--- /dev/null
+++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/TwoFaTotp.kt
@@ -0,0 +1,83 @@
+package com.github.nullptroma.wallenc.usecases
+
+import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator
+import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
+import java.time.Duration
+import java.time.Instant
+import javax.crypto.spec.SecretKeySpec
+
+data class TwoFaCodeState(
+ val code: String,
+ val secondsUntilRefresh: Int,
+)
+
+fun buildTwoFaCodeState(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? {
+ val period = token.periodSeconds.coerceAtLeast(1)
+ val remaining = (period - ((nowMillis / 1000L) % period)).toInt().coerceAtLeast(0)
+ val code = generateTotpCode(
+ secret = token.secret,
+ nowMillis = nowMillis,
+ digits = token.digits.coerceIn(6, 8),
+ periodSeconds = token.periodSeconds.coerceAtLeast(1),
+ algorithm = token.algorithm,
+ ) ?: return null
+ return TwoFaCodeState(
+ code = code,
+ secondsUntilRefresh = remaining,
+ )
+}
+
+private fun generateTotpCode(
+ secret: String,
+ nowMillis: Long,
+ digits: Int,
+ periodSeconds: Int,
+ algorithm: String,
+): String? {
+ val key = decodeBase32(secret) ?: return null
+ val macAlgorithm = when (algorithm.trim().uppercase().replace("-", "").replace("_", "")) {
+ "SHA256" -> "HmacSHA256"
+ "SHA512" -> "HmacSHA512"
+ else -> "HmacSHA1"
+ }
+ return runCatching {
+ val generator = TimeBasedOneTimePasswordGenerator(
+ Duration.ofSeconds(periodSeconds.toLong()),
+ digits,
+ macAlgorithm,
+ )
+ val secretKey = SecretKeySpec(key, "RAW")
+ val otp = generator.generateOneTimePassword(
+ secretKey,
+ Instant.ofEpochMilli(nowMillis),
+ )
+ otp.toString().padStart(digits, '0')
+ }.getOrNull()
+}
+
+private fun decodeBase32(input: String): ByteArray? {
+ val clean = input
+ .trim()
+ .replace(" ", "")
+ .replace("-", "")
+ .replace("=", "")
+ .uppercase()
+ if (clean.isEmpty()) return null
+
+ var buffer = 0
+ var bitsLeft = 0
+ val out = ArrayList(clean.length * 5 / 8)
+ for (ch in clean) {
+ val value = BASE32_ALPHABET.indexOf(ch)
+ if (value < 0) return null
+ buffer = (buffer shl 5) or value
+ bitsLeft += 5
+ if (bitsLeft >= 8) {
+ out.add(((buffer shr (bitsLeft - 8)) and 0xFF).toByte())
+ bitsLeft -= 8
+ }
+ }
+ return out.toByteArray()
+}
+
+private const val BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"