Работающий TOTP 2fa
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ data class TwoFaTokenRecord(
|
|||||||
val account: String,
|
val account: String,
|
||||||
val secret: String,
|
val secret: String,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
|
val digits: Int = 6,
|
||||||
|
val periodSeconds: Int = 30,
|
||||||
|
val algorithm: String = "SHA1",
|
||||||
)
|
)
|
||||||
|
|
||||||
data class TextSecretEntryRecord(
|
data class TextSecretEntryRecord(
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ retrofit = "3.0.0"
|
|||||||
okhttp = "5.3.2"
|
okhttp = "5.3.2"
|
||||||
workRuntime = "2.10.0"
|
workRuntime = "2.10.0"
|
||||||
hiltWork = "1.3.0"
|
hiltWork = "1.3.0"
|
||||||
|
cameraX = "1.5.0"
|
||||||
|
mlkitBarcode = "17.3.0"
|
||||||
|
javaOtp = "0.4.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" }
|
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" }
|
||||||
@@ -65,6 +68,12 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp
|
|||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
|
||||||
|
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" }
|
||||||
|
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" }
|
||||||
|
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" }
|
||||||
|
mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" }
|
||||||
|
java-otp = { group = "com.eatthepath", name = "java-otp", version.ref = "javaOtp" }
|
||||||
|
|
||||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ dependencies {
|
|||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
implementation(libs.androidx.material.icons.extended)
|
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)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,37 @@
|
|||||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
|
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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
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.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
import androidx.compose.material.icons.outlined.Lock
|
import androidx.compose.material.icons.outlined.Lock
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
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.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -27,21 +39,46 @@ import androidx.compose.material3.LinearProgressIndicator
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
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.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.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.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
|
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
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
|
@Composable
|
||||||
fun TwoFaTokensScreen(
|
fun TwoFaTokensScreen(
|
||||||
@@ -49,6 +86,13 @@ fun TwoFaTokensScreen(
|
|||||||
viewModel: TwoFaTokensViewModel = hiltViewModel(),
|
viewModel: TwoFaTokensViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.state.collectAsStateWithLifecycle()
|
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 editingToken by remember { mutableStateOf<TwoFaTokenRecord?>(null) }
|
||||||
var creating by remember { mutableStateOf(false) }
|
var creating by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -95,6 +139,7 @@ fun TwoFaTokensScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
val codeState = rememberTwoFaCode(item, nowMillis)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -123,12 +168,6 @@ fun TwoFaTokensScreen(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(4.dp))
|
|
||||||
Text(
|
|
||||||
text = maskedSecret(item.secret),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row {
|
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,
|
startValue = null,
|
||||||
isBusy = uiState.isMutating,
|
isBusy = uiState.isMutating,
|
||||||
onDismiss = { creating = false },
|
onDismiss = { creating = false },
|
||||||
onSave = { issuer, account, secret, notes ->
|
onSave = { issuer, account, secret, notes, digits, periodSeconds, algorithm ->
|
||||||
creating = false
|
creating = false
|
||||||
viewModel.saveToken(
|
viewModel.saveToken(
|
||||||
existingId = null,
|
existingId = null,
|
||||||
@@ -166,6 +283,9 @@ fun TwoFaTokensScreen(
|
|||||||
account = account,
|
account = account,
|
||||||
secret = secret,
|
secret = secret,
|
||||||
notes = notes,
|
notes = notes,
|
||||||
|
digits = digits,
|
||||||
|
periodSeconds = periodSeconds,
|
||||||
|
algorithm = algorithm,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -176,7 +296,7 @@ fun TwoFaTokensScreen(
|
|||||||
startValue = token,
|
startValue = token,
|
||||||
isBusy = uiState.isMutating,
|
isBusy = uiState.isMutating,
|
||||||
onDismiss = { editingToken = null },
|
onDismiss = { editingToken = null },
|
||||||
onSave = { issuer, account, secret, notes ->
|
onSave = { issuer, account, secret, notes, digits, periodSeconds, algorithm ->
|
||||||
editingToken = null
|
editingToken = null
|
||||||
viewModel.saveToken(
|
viewModel.saveToken(
|
||||||
existingId = token.id,
|
existingId = token.id,
|
||||||
@@ -184,23 +304,47 @@ fun TwoFaTokensScreen(
|
|||||||
account = account,
|
account = account,
|
||||||
secret = secret,
|
secret = secret,
|
||||||
notes = notes,
|
notes = notes,
|
||||||
|
digits = digits,
|
||||||
|
periodSeconds = periodSeconds,
|
||||||
|
algorithm = algorithm,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TwoFaTokenEditDialog(
|
private fun TwoFaTokenEditDialog(
|
||||||
startValue: TwoFaTokenRecord?,
|
startValue: TwoFaTokenRecord?,
|
||||||
isBusy: Boolean,
|
isBusy: Boolean,
|
||||||
onDismiss: () -> Unit,
|
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 issuer by remember(startValue) { mutableStateOf(startValue?.issuer.orEmpty()) }
|
||||||
var account by remember(startValue) { mutableStateOf(startValue?.account.orEmpty()) }
|
var account by remember(startValue) { mutableStateOf(startValue?.account.orEmpty()) }
|
||||||
var secret by remember(startValue) { mutableStateOf(startValue?.secret.orEmpty()) }
|
var secret by remember(startValue) { mutableStateOf(startValue?.secret.orEmpty()) }
|
||||||
var notes by remember(startValue) { mutableStateOf(startValue?.notes.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(
|
AlertDialog(
|
||||||
onDismissRequest = { if (!isBusy) onDismiss() },
|
onDismissRequest = { if (!isBusy) onDismiss() },
|
||||||
@@ -214,40 +358,186 @@ private fun TwoFaTokenEditDialog(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Box(
|
||||||
if (isBusy) {
|
modifier = Modifier
|
||||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
.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 = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
enabled = !isBusy && issuer.isNotBlank() && account.isNotBlank() && secret.isNotBlank(),
|
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))
|
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 {
|
@Composable
|
||||||
if (secret.isBlank()) return "—"
|
private fun rememberTwoFaCode(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? {
|
||||||
return "••••••••••••"
|
return remember(token, nowMillis) {
|
||||||
|
buildTwoFaCodeState(token, nowMillis)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ class TwoFaTokensViewModel @Inject constructor(
|
|||||||
account: String,
|
account: String,
|
||||||
secret: String,
|
secret: String,
|
||||||
notes: String?,
|
notes: String?,
|
||||||
|
digits: Int = 6,
|
||||||
|
periodSeconds: Int = 30,
|
||||||
|
algorithm: String = "SHA1",
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
|
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
|
||||||
@@ -106,6 +109,9 @@ class TwoFaTokensViewModel @Inject constructor(
|
|||||||
account = account,
|
account = account,
|
||||||
secret = secret,
|
secret = secret,
|
||||||
notes = notes,
|
notes = notes,
|
||||||
|
digits = digits,
|
||||||
|
periodSeconds = periodSeconds,
|
||||||
|
algorithm = algorithm,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
manageTwoFaTokensUseCase.update(
|
manageTwoFaTokensUseCase.update(
|
||||||
@@ -116,6 +122,9 @@ class TwoFaTokensViewModel @Inject constructor(
|
|||||||
account = account,
|
account = account,
|
||||||
secret = secret,
|
secret = secret,
|
||||||
notes = notes,
|
notes = notes,
|
||||||
|
digits = digits,
|
||||||
|
periodSeconds = periodSeconds,
|
||||||
|
algorithm = algorithm,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,21 @@
|
|||||||
<string name="two_fa_field_account">Аккаунт</string>
|
<string name="two_fa_field_account">Аккаунт</string>
|
||||||
<string name="two_fa_field_secret">Секрет</string>
|
<string name="two_fa_field_secret">Секрет</string>
|
||||||
<string name="two_fa_field_notes_optional">Заметка (опционально)</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_create">Создать секрет</string>
|
||||||
<string name="text_secret_edit">Редактировать секрет</string>
|
<string name="text_secret_edit">Редактировать секрет</string>
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ dependencies {
|
|||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.java.otp)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ class ManageTwoFaTokensUseCase {
|
|||||||
account: String,
|
account: String,
|
||||||
secret: String,
|
secret: String,
|
||||||
notes: String? = null,
|
notes: String? = null,
|
||||||
|
digits: Int = 6,
|
||||||
|
periodSeconds: Int = 30,
|
||||||
|
algorithm: String = "SHA1",
|
||||||
): TwoFaTokenRecord = mutex.withLock {
|
): TwoFaTokenRecord = mutex.withLock {
|
||||||
val storage = storageInfo as? IStorage ?: error("Storage is not writable")
|
val storage = storageInfo as? IStorage ?: error("Storage is not writable")
|
||||||
val next = TwoFaTokenRecord(
|
val next = TwoFaTokenRecord(
|
||||||
@@ -57,6 +60,9 @@ class ManageTwoFaTokensUseCase {
|
|||||||
account = account.trim(),
|
account = account.trim(),
|
||||||
secret = secret.trim(),
|
secret = secret.trim(),
|
||||||
notes = notes?.trim().takeUnless { it.isNullOrBlank() },
|
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) }
|
val updated = readAll(storage).toMutableList().apply { add(next) }
|
||||||
writeAll(storage, updated)
|
writeAll(storage, updated)
|
||||||
@@ -108,12 +114,18 @@ class ManageTwoFaTokensUseCase {
|
|||||||
val account = obj["account"]?.jsonPrimitive?.contentOrNull ?: return null
|
val account = obj["account"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||||
val secret = obj["secret"]?.jsonPrimitive?.contentOrNull ?: return null
|
val secret = obj["secret"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||||
val notes = obj["notes"]?.jsonPrimitive?.contentOrNull
|
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(
|
return TwoFaTokenRecord(
|
||||||
id = id,
|
id = id,
|
||||||
issuer = issuer,
|
issuer = issuer,
|
||||||
account = account,
|
account = account,
|
||||||
secret = secret,
|
secret = secret,
|
||||||
notes = notes,
|
notes = notes,
|
||||||
|
digits = digits,
|
||||||
|
periodSeconds = periodSeconds,
|
||||||
|
algorithm = algorithm,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +135,22 @@ class ManageTwoFaTokensUseCase {
|
|||||||
put("account", JsonPrimitive(record.account))
|
put("account", JsonPrimitive(record.account))
|
||||||
put("secret", JsonPrimitive(record.secret))
|
put("secret", JsonPrimitive(record.secret))
|
||||||
record.notes?.let { put("notes", JsonPrimitive(it)) }
|
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 =
|
private fun domainFilePathEquals(left: String, right: String): Boolean =
|
||||||
|
|||||||
@@ -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<Byte>(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"
|
||||||
Reference in New Issue
Block a user